1. 从"能跑"到"能扛":Pi Agent 给我的三记重拳
1.1 第一记重拳:Demo 级别的引擎,进了生产就失灵
Pi Agent 跑通第一个真实业务的时候,我第一反应不是高兴,而是慌。那个在本地能完美回答问题的 Agent,一到生产环境就变成了定时炸弹:任务不结束、日志爆炸、稍一并发就 OOM。AIRUN 这个项目,就是被那几次事故逼出来的。如果你也在做 Agent 开发,或者正打算把一个 Agent 引擎推向企业级运行时,这篇文章建议看完再动手。
先说清楚我为什么选中 Pi Agent。当时团队要做一个内部知识问答和工单自动处理的 Agent,市面上框架一大堆,但我只想找一个足够轻、没有过度封装的开源引擎做底座。Pi Agent 恰好满足:模型无关、工具注册简单、核心循环一眼能看完。它更像一个"Agent 执行内核",不像 LangGraph 那样连编排都替你决定了。对一个想亲手掌控每一层行为的团队来说,这是很好的起点。
但坏也就坏在这个"轻"上。我拿它接了一个内部工单系统,让 Agent 根据用户描述查知识库、调工单 API、生成处理建议。本地联调十分钟出结果,日志干净。上线灰度第一天就露馅:同一时间进来 20 个工单,前几个还能出结果,第五个开始整体卡住,第九个直接超时,最后进程被 OOM Killer 带走。那一刻我意识到,Pi Agent 是一台能点火的发动机,但 AIRUN 需要的是一台有刹车、有仪表盘、有安全气囊的整车。
1.2 第二记重拳:你以为的并发,其实是串行
第一次事故的根因,说出来有点丢人:Pi Agent 的执行循环里,所有状态都挂在一个全局的 session dict 上。单用户跑没问题,多用户同时进来,大家的 messages 队列互相覆盖,工具调用结果串到了别人的会话里。我在日志里看到 A 用户的查询被 B 用户的知识库结果污染,整个人是懵的。
这反映出一个很根本的问题:引擎当初是按"一个进程里跑一个 Agent"设计的,根本没想过"一个进程里跑一万个会话"这件事。企业级运行时和 Demo 引擎的第一个分水岭就在这里:你要不要为每个会话做隔离?要不要为每次并发做资源限制?要不要为每个用户的上下文做独立存储?
我当时用最简单的方式验证了一把:把会话粒度从进程级改成对象级,用 Redis 做会话状态持久化,每个 Agent 实例绑定一个 session_id。改造后并发不再互相污染,但新的问题又来了:Pi Agent 的执行模型是同步的,一个 Agent 在等工具返回时,整个进程都在等。用户感受到的"并发"其实只是排队,根本不是并行处理。这逼着我去思考:运行时必须自己有一套调度器,而不是依赖引擎的天然行为。
1.3 第三记重拳:Agent 失控时,谁都叫不停它
最让我脊背发凉的事故发生在一次演示上。现场让 Agent 做一份数据分析,它的工具调用链突然陷入循环:反复读同一份文件、反复调用同一个统计函数,每次都把同样的结果写进上下文,然后继续调用。Pi Agent 原生的 max_steps 参数设的是 10,按理说 10 轮就该停了,但它在第 8 轮调了一个写文件的工具,工具返回值把上下文撑大,随后每轮都在膨胀,最后请求超时,连带拖垮了同一进程里的其他任务。
这次事故教会我一件事:循环上限不是保险丝,是最后一道没有意义的闸门。Agent 可以在上限之内反复做无效调用,也可以在上限之外因为工具副作用而让系统内存爆炸。"叫停"必须是运行时的基本能力,不能靠引擎里的一个数字。你得能随时查看每个会话在干什么,能强行终止一个任务,能从外部改变它的下一步行为。这些能力 Pi Agent 一概没有。
这三记重拳打完之后,我做了个决定:不修修补补,直接基于 Pi Agent 的执行思想重写一个企业级运行时,代号 AIRUN。
2. 引擎、框架、运行时:先把边界划清楚,再造轮子
2.1 为什么 AIRUN 不叫"框架",而叫"运行时"
很多做 Agent 的朋友喜欢把"框架"和"运行时"混着用,结果讨论了半天发现说的不是一回事。我自己的划分很简单:
- 框架解决"代码怎么写"。它给你一套脚手架、抽象类、工具函数,帮你组织 Agent 的逻辑。比如你定义几个 Tool、写一个 Prompt Template、挂一个 Memory 对象,这是框架的活。
- 引擎解决"执行怎么跑"。它负责把模型调用、工具调用、上下文拼装、多步推理这些核心动作真正执行起来。Pi Agent 的价值在这层。
- 运行时解决"系统怎么扛"。它在引擎之外,负责进程生命周期、并发调度、超时熔断、资源隔离、可观测性、异常恢复、热更新、安全审计。引擎决定一个 Agent 能不能跑,运行时决定一万个 Agent 能不能稳定跑。
打个比方:框架是汽车的维修手册,引擎是发动机,运行时是整车。你可以有好发动机,但没有刹车、没有仪表盘、没有安全气囊,这车上不了路。AIRUN 这个名字就是 "AI Runtime" 的缩写,团队内部叫它"爱 run",说得很直白:我们不做框架,不写业务编排,就专门解决"把 Agent 执行放进企业系统里还能正常运转"这件事。
2.2 Pi Agent 的引擎部分,哪些留着、哪些必须拆
重写不是推翻重来。我梳理了 Pi Agent 的执行核心,把有价值的部分保留下来,把有问题的部分彻底拆掉。
保留的:
- 模型无关的 Model Adapter。Pi Agent 对模型接口的抽象写得不错,底层是 OpenAI 还是本地模型,对上层是透明的。这个设计直接继承到 AIRUN。
- 工具注册表。它的工具注册方式很干净,函数 + schema,不需要额外定义复杂的 protocol。这个也留着。
- 多步推理的消息循环。系统提示词、用户消息、工具返回、中间推理结果按顺序拼装,这个循环本身没有大问题,问题在循环外面的控制。
必须拆的:
- 全局 session 状态。Pi Agent 把会话上下文存在模块级 dict 里,这就是并发污染的根源。AIRUN 改成每个会话一个独立的状态存储,序列化之后进 Redis。
- 同步执行链。原引擎的所有步骤在同一个 async 函数里串行跑,一个工具卡住全链路卡住。AIRUN 把每一步抽象成可调度的工作单元,交给任务队列去跑。
- 无观测的日志。原引擎只有 print,出事之后只能靠猜。AIRUN 从第一步就开始打结构化日志,每次模型调用、工具调用都有 trace_id 贯穿。
- 固定循环上限。替换成了步骤预算 + token 预算 + 墙钟时间三重限制。
改造后的执行核心大概是这个样子:
# AIRUN 里一个会话的一次执行迭代(简化版) async def execute_step(session): budget = session.budget if budget.remaining_steps() <= 0: return session.terminate(reason="step_budget_exceeded") if budget.remaining_tokens() <= 0: return session.terminate(reason="token_budget_exceeded") if session.deadline.exceeded(): return session.terminate(reason="session_timeout") # 记录 trace,方便事后回放 with tracer.start_span(f"execution_step-{session.step_id}") as span: response = await session.model_adapter.chat(session.messages) tool_calls = parse_tool_calls(response) if not tool_calls: return session.finish(response.content) for call in tool_calls: # 工具调用单独走超时和熔断,不再和模型调用混在一起 result = await dispatch_tool_call(session, call) session.messages.append(tool_result_message(call, result))2.3 一条界线:引擎负责"跑得动",运行时负责"停得下"
我后来给自己总结了一条判断标准:一个组件该放引擎还是放运行时,就看它是在帮助 Agent 跑得更聪明,还是在防止 Agent 跑得更危险。模型调用、工具解析、上下文拼装,这些是让 Agent"跑得动"的能力,留在引擎层。超时、熔断、降级、资源控制、权限校验、审计日志,这些是让系统"停得下"的机制,全部提到运行时层。
这个界线一划清楚,很多争论就不存在了。比如"给 Agent 加一个循环上限"这种需求,如果你在引擎层写一个参数,那只是多了个开关;但如果你在运行时层做一个 step budget 的强制校验,它就成了所有会话的统一约束,任何 Agent 都没法绕过去。AIRUN 里我采用了后者,每个会话在创建时就绑定一个 budget 对象,执行迭代的第一步就是检查预算,而不是等引擎自己想起来要停。
3. AIRUN 核心设计:用状态机管住每一个会话
3.1 会话状态机:把 Agent 的一次执行拆成 9 个状态
没有状态机的 Agent 运行时,本质上就是在裸奔。Pi Agent 时代我根本不知道一个任务执行到哪一步了,出了错也没法恢复。AIRUN 里我给每个会话定义了一套显式状态机,所有状态转换都经过运行时统一管理。
| 状态 | 含义 | 可进入的来源 |
|---|---|---|
| CREATED | 会话已创建,等待调度 | - |
| QUEUED | 已进入任务队列,等待执行 | CREATED |
| RUNNING | 正在执行 LLM 调用或推理 | QUEUED, PAUSED |
| WAITING_TOOL | 正在等待某个工具调用返回 | RUNNING |
| WAITING_HUMAN | 需要人工介入才能继续 | RUNNING, TOOL_ERROR |
| RECOVERING | 发生可重试错误,正在恢复 | TOOL_ERROR, API_ERROR |
| SUCCEEDED | 正常完成 | RUNNING |
| FAILED | 不可恢复的错误 | RUNNING, RECOVERING |
| TERMINATED | 被外部强制终止 | 任意运行时状态 |
这里有个细节值得说道:WAITING_HUMAN 状态是我后来加的。早期测试里 Agent 遇到权限不足的工具会反复尝试,浪费预算。后来在设计上规定,Agent 遇到"人类才能决策"的情况必须把会话挂起,推到人工审批队列,而不是自己瞎猜。这个设计让运行时天然拥有了"人机协作"的能力,也让权限边界从技术上变得可执行。
状态不是图个好看,它必须落到存储里。AIRUN 的做法是每次状态迁移都写成一条 event 记录,写进 PostgreSQL。任何时刻你问"某个会话现在在干什么",都能从事件表里还原出来。这也为断点恢复提供了基础:进程重启后,把 RUNNING 状态的会话捞出来,根据 events 重建上下文,重新入队继续跑。
3.2 三道保险:超时、熔断、降级
状态机解决了"看得见"的问题,但真正要命的是"停不下来"。我给 AIRUN 上了三道保险,对应三个不同层面的失控风险。
第一道是三级超时。单次 LLM 调用 30 秒必须返回;单次工具调用 60 秒必须返回;整个会话最长 15 分钟。任何一个超时都会触发相应处理,而不是无声无息地卡住。比如工具调用超时,先记录一次 timeout 事件,然后根据策略决定是重试一次还是直接把会话切到 FAILED,再或者联系人工处理。
第二道是熔断。熔断用于保护下游系统不被 Agent 的疯狂调用打垮。AIRUN 里每个外部依赖都有一个独立的断路器,以 60 秒为窗口统计调用失败率,超过 50% 就打开断路器,接下来 30 秒内所有发往该依赖的请求直接快速失败,不再真正发起调用。这个机制在「外部 API 抖动」的场景里救了我很多次,后面我会具体讲。
第三道是降级。降级的核心思想是:Agent 不是所有任务的最优解。有一些简单重复的动作,直接用规则引擎处理比走大模型更快更稳。AIRUN 允许在任务进队列之前先过一个规则匹配器,命中规则的任务根本不进 Agent 引擎。比如工单分类这种活,正则表达式就能搞定 80%,没必要让 Agent 跑一遍。
配置大概是这样的:
runtime: timeouts: llm_call: 30s tool_call: 60s session: 15m human_wait: 24h circuit_breaker: window: 60s failure_rate: 0.5 cooldown: 30s fallback_rules: enabled: true prefilter: true3.3 记忆工程化:短期、中期、长期记忆各归其位
Agent 的记忆是热搜词里被问烂的问题,也是实际做起来最容易踩坑的地方。很多团队把"记忆"理解为"把历史消息全部拼进 prompt",结果上下文越滚越长,钱越花越多,效果还越来越差。AIRUN 里我把记忆分成三级,每一级有明确的存储介质和生命周期。
| 记忆类型 | 存储位置 | 生命周期 | 典型用途 |
|---|---|---|---|
| 短期记忆 | 当前会话上下文字段 | 会话结束即销毁 | 多轮对话中的用户意图、临时变量 |
| 中期记忆 | PostgreSQL + pgvector | 按项目/任务维度保留 | 该业务领域的历史决策、常用工具选择 |
| 长期记忆 | 数据仓库 + 向量化归档 | 按组织策略保留 | 用户偏好、组织知识沉淀、策略模板 |
实践中我发现一个原则:不要把检索出来的所有记忆都塞给模型,每个层级只取 Top-K,而且要带时间衰减。比如中期记忆,按相关度排序后取前 5 条,每条附上"这是三个月前的一次类似工单的处理方式"。模型有这些锚点就足够做出稳定决策,不需要把历史全部灌进去。
记忆工程还有一个容易忽略的点:写记忆比读记忆更难。Agent 不能自己决定"这个信息很重要,我要记住",那会导致记忆库被垃圾塞满。AIRUN 的做法是:Agent 产生的所有信息先进临时缓冲区,由运行时的一个记忆过滤器决定哪些值得沉淀到中期库,哪些值得进长期库。过滤器目前是一套规则 + 关键字段提取,后续可以升级成一个小模型判断,但核心原则不变:记忆的写入权在运行时,不在 Agent。
4. 从单 Agent 到多 Agent:编排层、Skill 和权限边界
4.1 Harness 与 Agent 的区别:谁是大脑,谁是项目经理
做 Agent 平台化的过程中,很多同事会问:既然每个 Agent 都能自主决策,那还要编排层干嘛?我通常这么解释:Agent 是那个干活的大脑,Harness(编排器)是那个盯着截止日期、分配任务、处理突发状况的项目经理。
单 Agent 场景下,你不太容易感觉到 Harness 的必要性,因为所有决策都发生在 Agent 内部。但一旦上了多 Agent 协作,问题立刻复杂:多个 Agent 之间是串行还是并行?某个 Agent 失败后是重跑还是换一个?任务之间的数据依赖怎么传递?谁来决定整个工作流已完成?
AIRUN 的编排层是一个独立于 Agent 引擎的组件。它定义任务 DAG、执行优先级、重试策略和条件分支。Agent 只负责"当前这一步怎么做",Harness 负责"整个流程怎么走"。这个分工带来一个明显的好处:你可以单独升级某个 Agent 的能力,而完全不用改动编排流程;反过来,你也可以调整编排顺序,而不用动 Agent 的内部逻辑。
一个容易犯的错误是把编排逻辑写死在 Agent 的 prompt 里。比如让 Agent "先查库存,再下单,如果失败就换供应商",这串逻辑看起来没问题,但一旦角色复杂化,prompt 会膨胀到失控,而且无法做精细的重试和审计。AIRUN 里这些全都提到 Harness 层用代码表达,Agent 只负责执行单个已经定义好的动作。
4.2 Skill 不是 Agent:能力复用与控制权分离
另一个高频问题是 Skill 和 Agent 的区别。我自己踩过的弯路是把 Skill 当成微缩版 Agent,给每个 Skill 都配了一套 prompt 和决策逻辑,结果就是一个 Skill 里又藏了一个小 Agent,系统复杂度直接爆炸。
后来我定了规矩:Skill 是能力包,不是决策主体。一个 Skill 可以是一个 Python 函数、一个 API 封装、一套知识库检索逻辑。它只负责"把一件事做对",不负责"决定要不要做这件事"。决策权永远在 Agent 手里,Agent 根据任务目标选择合适的 Skill,调用之后把结果拿回来自己判断下一步。
AIRUN 里 Agent 与 Skill 的关系是显式注册的。Agent 创建时声明自己能调哪些 Skill,Skill 不能反过来决定流程。这种单向依赖让权限控制变得非常简单:一个 Skill 能被哪些 Agent 调用,在白名单里写清楚,运行时强制校验。如果是双向依赖,权限就会变成一团乱麻。
4.3 工具权限与审计:Agent 能调什么,必须谁说了算
企业里做 Agent,最敏感的就是工具权限。一个能自主执行工单操作的 Agent,如果权限设计不当,可能做出不可逆的操作。AIRUN 里我把工具分成三类:
- 只读类:查询知识库、读取业务数据、搜索日志。这类工具 Agent 可以任意调用。
- 写入类:创建工单、修改配置、发送消息。这类工具要求 Agent 携带明确的操作意图,并且每条记录都写审计日志。
- 高危类:删除数据、批量操作、资金相关操作。这类工具默认禁止 Agent 直接调用,必须转人工审批,也就是前面说的 WAITING_HUMAN 状态。
权限模型的落地不只是在代码里加 if 判断。我的经验是把权限校验放到运行时的一个独立中间件里,而不是让 Agent 引擎自己去判断。这样任何 Agent、任何 Skill 都没法绕开权限机制,因为运行时根本不把高危工具暴露给引擎层调用链。
审计这一块同样不能省。每次工具调用都要记录:谁(哪个 Agent)、在哪个会话、为了什么任务、调了哪个工具、传入什么参数、返回什么结果、耗时多少、成功还是失败。这些日志统一存到单独的审计存储,和应用日志物理隔离。等真的出问题时,这套审计链能让你回溯到每一个决策点。
5. 生产环境迁移实录:三道坎,以及完整的排查链路
5.1 坎一:写个二叉树程序,Agent 却卡死了 47 分钟
上线后遇到的第一个大 Bug,是我印象最深刻的"运行时错误"。业务方让 Agent 写一段二叉树的层序遍历代码并在沙箱里执行验证,Agent 很快给出了代码,然后卡在验证环节。AIRUN 控制台显示会话停留在 WAITING_TOOL 状态,从开始到被发现整整 47 分钟。
我当时的排查链路是这样的:
- 先看会话事件表,确认停在哪个工具上——一个叫
run_sandbox_code的执行工具。 - 看工具的超时配置,发现这轮部署时我改过配置,但新配置没有生效,旧配置里 tool_call 超时是默认值,而这个默认值依赖引擎的 max_steps,恰好没有对这个工具做单独限制。
- 手动复现:把 Agent 生成的那段二叉树代码拿到沙箱里跑,发现
while root or stack:这个循环条件写错了,root永远不为空,死循环。 - 确认根因后修了两个层面:沙箱执行必须带 CPU 指令数限制;工具层加独立的强制超时,不再依赖引擎默认参数。
- 补了一条测试用例:所有沙箱执行类工具,运行前必须做静态循环检测,运行中必须有指令数上限。
这个坑的本质是Agent 写出来的代码可能死循环,而你的运行时没防住这一层。很多人防了"模型不返回结果",却没防"工具返回结果但工具本身在死循环"。AIRUN 后来给所有执行类工具加了一层 wrapper,传入的代码执行脚本统一带上ulimit -t 5之类的限制,从根本上杜绝无限循环。
5.2 坎二:外部 API 一抖动,全线 Agent 跟着殉葬
有一次外部 CRM 系统做版本升级,API 响应变得极慢。我们的 Agent 调度了大量"查客户资料"的任务,结果所有任务都卡在同一类查询上。更糟糕的是,Agent 的默认行为是"没拿到数据就重试",于是每个会话都在无限重试,把 CRM API 打得雪上加霜,形成雪崩。
这个问题靠三层解决。第一层是每个外部 API 都进熔断器,这是 AIRUN 自带的能力,但早期为了快速上线,我跳过了熔断配置,这是教训。第二层是重试要带指数退避和抖动,不能用固定间隔。第三层是Agent 的 prompt 里约定:工具调用失败超过一次就放弃该路径,转而向用户报告"暂时不可用",而不是无限重试。
这轮的修复代码后来被抽成了公共组件:
async def call_with_resilience(session, func, *, max_retries=2, breaker=None): for attempt in range(max_retries + 1): if breaker and not breaker.allow_request(): raise CircuitOpenError("下游服务熔断中,快速失败") try: result = await func() if breaker: breaker.record_success() return result except (RemoteTimeout, RemoteUnavailable) as e: if attempt >= max_retries: # 不再重试,把控制权交回 Agent 决策层 return ToolUnavailable(reason=str(e)) if breaker: breaker.record_failure() backoff = (2 ** attempt) * 0.5 # 指数退避 await asyncio.sleep(backoff + random.uniform(0, 0.1)) # 加抖动经历过这次之后,我把"外部 API 依赖策略"写进了 AIRUN 的部署检查项:凡是接入的新工具,必须明确声明重试次数、超时时间和是否允许降级。不允许工具默默重试。
5.3 坎三:热更新把新技能推给了一半旧会话
上线运行稳定后,我开始给 Agent 更新技能包。第一次热更新后观察到一个诡异现象:测试环境完全正常,生产环境却出现同一类任务结果忽好忽坏,看日志发现有一部分会话加载的是新技能代码,另一部分还在跑旧技能代码,而这两类会话混在同一个队列里。
根因是AIRUN 直接改动了技能模块的函数引用,已经运行中的会话在下一次工具调用时拿到的可能是新代码。对于有状态的长会话来说,这会造成不可预期的行为:一个会话前一半跑在旧版本上,后一半跑在新版本上,中间的逻辑就可能对不上。
修复方案是版本快照 + 会话粘滞。每个会话在创建时记录一个技能包版本号和代码快照指针,执行过程中始终加载这个快照对应的代码。更新技能包时,新会话用新版本,旧会话继续用旧版本直到结束。只有重启整个运行时时,才允许所有会话强制迁移到新版本。这有点类似 K8s 滚动更新的思路,但粒度从 Pod 细化到了会话。
这个设计带来的副作用是存储占用变大了,因为要保留多个版本的技能包。但这是值得的:一致性比省存储重要得多。一个诡异的线上问题排查成本,够你买十倍的存储。
6. 留给后来者的验收清单和经验
6.1 一份可以复用的企业级 Agent 运行时验收清单
如果你也在做 Agent 运行时,或者正打算把某个开源引擎推上生产,我建议你拿这份清单自查一遍:
| 检查项 | 标准 | 常见失败表现 |
|---|---|---|
| 会话隔离 | 不同会话的状态存储物理隔离 | 并发后上下文互相污染 |
| 强制超时 | 模型调用/工具调用/整会话都有独立超时 | 一个卡住的工具拖死全链路 |
| 熔断保护 | 下游依赖故障时快速失败 | 依赖抖动导致全线雪崩 |
| 状态可观测 | 每个会话当前状态可查、历史可回放 | 出问题只能靠猜日志 |
| 资源限制 | 步骤数、token 数、内存都有上限 | 单个 Agent 打爆整个进程 |
| 权限边界 | 高危工具必须有独立审批链路 | Agent 私自执行不可逆操作 |
| 审计完整性 | 每次模型调用和工具调用都留痕 | 无法回溯错误决策源头 |
| 热更新一致性 | 运行中会话绑定旧版本快照 | 新旧代码混跑产生诡异结果 |
| 逃生通道 | 任何会话都可被外部强制终止 | 失控任务无法停止 |
这张表不是设计文档里写写就完的,每一条都应该有对应的自动化检查。比如"会话隔离"这一项,AIRUN 里我写了一个压力测试用例:同时开 100 个会话,每个会话发不同的任务,最后校验每个会话拿到的结果是否等于它自己任务对应的答案。这个用例在每次 CI 里都会跑。
6.2 最后一句实话:先解决失控,再追求智能
做完了从 Pi Agent 到 AIRUN 的迁移,我最大的体会是:Agent 的"智能"是锦上添花,"可控"才是雪中送炭。一个偶尔答偏但永远 3 秒内返回、状态清晰、能随时终止的 Agent,比一个聪明但偶尔失控的 Agent 有价值得多。企业系统里,正确地失败比华丽地成功更重要。
如果让我重新来一遍,我会从画状态机开始,而不是从调 prompt 开始。跑通 Pi Agent 只是拿到了发动机,AIRUN 让它成为一台能上路、能刹车、能被安全拦下来的车。这份迁移记录如果能帮你少踩一个坑,那就值了。