1. 为什么“能跑通Demo”和“能上生产”之间隔着一道鸿沟
如果你最近半年动手搭过Agent,大概率经历过这样的曲线:第一天兴致勃勃调通一个ReAct循环,让模型自己决定调哪个工具,感觉“智能”触手可及;第三天开始加第二个、第三个工具,发现模型开始乱选;第五天接入真实业务数据,发现它偶尔编造参数、偶尔死循环、偶尔把两个工具的返回结果张冠李戴;第七天你想加个“人工确认”环节,结果整个链路改得面目全非。
这不是你的问题,这是Agent工程化的必经阶段。Agent Runtime这个词最近被反复提起,但很多人把它和“Agent框架”混为一谈。框架解决的是“怎么写”,Runtime解决的是“怎么稳、怎么可控、怎么可观测、怎么可扩展”。前者是开发体验,后者是生产属性。
我自己的判断标准很简单:一个Agent系统如果只能在一个脚本里跑通一次任务,那它叫Demo;如果它能同时处理几十上百个并发会话、每个会话有独立的状态和记忆、工具调用失败能自动降级、每一步决策都能回溯、新增一个工具不需要改核心代码——那它背后一定有一套像样的Runtime。
这篇文章想做的事情,是把Agent编排这件事从“调API”拉到“做系统”的层面。我会按我实际搭建和踩坑的顺序,把底层原理、核心组件、工程落地中的真实取舍讲清楚。适合两类人看:一类是已经写过Agent Demo、想往生产环境推的开发者;另一类是想理解Agent系统内部到底由哪些部件组成、各自解决什么问题的技术负责人。全文不涉及任何具体平台的绑定,讲的是通用机制。
2. 拆开一个Agent Runtime:它到底由哪些部件咬合而成
2.1 从“一次模型调用”到“一个持续运行的循环”
大多数人第一次接触Agent,是从一个while循环开始的:把用户输入和工具描述塞给模型,模型返回一个工具调用请求,执行工具,把结果塞回去,再问模型,直到模型返回最终答案。这个循环就是Agent的心跳。
但生产级的Runtime里,这个循环远比看上去复杂。它至少要处理四件事:循环的终止条件(不是模型说停就停,还要有最大步数、超时、预算控制)、每一步的状态快照(用于中断恢复和审计)、工具调用的路由与鉴权(不同工具可能有不同的权限和限流)、异常的分类处理(模型返回格式错误、工具超时、工具返回业务错误,处理方式完全不同)。
我见过太多项目把这几件事全塞在一个函数里,结果就是加一个功能就要动核心循环,改一处崩三处。正确的做法是把循环本身抽象成一个状态机,每个节点是一个明确的阶段:规划、选择工具、执行工具、观察结果、判断是否继续。状态机的好处是每一步都可以被拦截、被记录、被替换。
2.2 规划器、执行器、记忆体:三个必须解耦的角色
一个常见的误区是把“规划”和“执行”揉在一起。模型既要想下一步做什么,又要直接输出工具参数,还要根据结果调整计划。这在简单任务里没问题,一旦任务链变长,模型很容易“忘记”最初的目标。
我的做法是把Runtime拆成三个逻辑角色:
- 规划器(Planner):负责把用户目标拆成子任务序列,或者在每一步决定“下一个动作是什么”。它不关心工具怎么调,只关心策略。
- 执行器(Executor):负责把规划器给出的动作翻译成具体的工具调用,处理参数校验、重试、超时、结果格式化。
- 记忆体(Memory):负责维护短期上下文(当前会话的对话历史)和长期记忆(跨会话的事实、偏好、历史结论)。
这三个角色解耦之后,你可以单独替换规划策略(比如从ReAct换成Plan-and-Execute),单独给执行器加限流和熔断,单独给记忆体加向量检索或摘要压缩,互不影响。这是Runtime可演进的前提。
2.3 工具层不是“函数列表”,而是一个带元数据的注册中心
很多人把工具定义成一个JSON schema就完事了。但在Runtime里,工具需要携带的元数据远不止参数描述:权限标签(哪些角色可以调用)、幂等性标记(失败能否安全重试)、成本估算(调用一次大概多少token或多少钱)、超时阈值、降级策略(失败后是重试、换工具还是直接返回错误)。
我习惯把工具注册中心设计成一张表,每个工具是一个条目,Runtime在决定调用前先查这张表做准入判断。这样做的好处是,新增工具只需要注册,不需要改循环逻辑;同时可以在注册层统一做审计埋点,哪个工具被调了多少次、成功率多少、平均耗时多少,一目了然。
| 元数据字段 | 作用 | 缺失后果 |
|---|---|---|
| 权限标签 | 控制谁能调用 | 越权调用,安全风险 |
| 幂等性标记 | 决定能否重试 | 重复下单、重复扣款 |
| 成本估算 | 预算控制 | 账单失控 |
| 超时阈值 | 防止挂死 | 整个会话卡住 |
| 降级策略 | 失败兜底 | 一步失败全盘失败 |
这张表是Runtime的“工具宪法”,所有调用行为都要过这一关。
3. 编排模式的选择:ReAct、Plan-and-Execute还是图编排
3.1 ReAct的甜区与它的天花板
ReAct(Reasoning + Acting)是最直观的编排模式:模型每一步都先“想一下”,再决定调不调工具、调哪个。它的优点是灵活,适合探索性任务,比如“帮我查一下这个报错可能是什么原因”,模型可以根据中间结果随时调整方向。
但ReAct的天花板也很明显。第一,步数不可控,模型可能在一个死胡同里反复尝试,消耗大量token。第二,全局视野弱,它只看当前上下文,容易做出局部最优但全局绕路的决策。第三,难以并行,因为每一步都依赖上一步的观察结果。
我的经验是:ReAct适合短链路、高不确定性的任务,比如信息检索、故障初筛。一旦任务步骤超过七八步,或者有明确的阶段划分,就该考虑换模式。
3.2 Plan-and-Execute:先画图纸再施工
Plan-and-Execute的思路是先让模型生成一个完整的任务计划(一个子任务列表),然后逐个执行,执行过程中可以根据结果修订计划。它比ReAct多了一个“全局规划”的阶段,适合步骤明确、依赖关系清晰的任务,比如“从三个数据源拉数据、清洗、合并、生成报告”。
这个模式的关键在于计划的表示形式。如果计划只是一段自然语言,执行器很难精确解析。我通常要求规划器输出结构化的计划,每个子任务包含:任务描述、依赖的前置任务、预期输出格式、可用的工具范围。这样执行器可以按依赖顺序调度,甚至把无依赖的子任务并行执行。
但Plan-and-Execute也有坑:计划一旦生成,模型容易“死守”计划,即使中间结果已经表明原计划不可行,它也不愿意改。解决办法是在每个子任务执行后加一个“计划校验”步骤,让模型判断是否需要修订,而不是闷头执行到底。
3.3 图编排:把控制流显式画出来
当任务复杂到一定程度,自然语言描述的控制流就不够用了。这时候需要图编排:把每个节点定义成一个明确的操作(调模型、调工具、做判断、做转换),边定义流转条件。整个执行过程就是一张有向图的遍历。
图编排的最大价值是可控性和可观测性。每个节点的输入输出都是明确的,可以在任意节点插入检查点、人工确认、条件分支。对于需要严格合规审计的场景,图编排几乎是唯一选择。
但它的代价是灵活性下降。图是预先定义好的,遇到图里没有的分支就抓瞎。所以我的实践是混合使用:外层用图编排定义主干流程和关键检查点,内层的某个节点用ReAct做灵活探索。这样既有骨架的稳定,又有关节的灵活。
| 编排模式 | 适合场景 | 主要风险 | 控制粒度 |
|---|---|---|---|
| ReAct | 短链路探索 | 步数失控 | 低 |
| Plan-and-Execute | 步骤明确的流水线 | 计划僵化 | 中 |
| 图编排 | 合规审计、复杂分支 | 灵活性差 | 高 |
| 混合模式 | 生产级复杂任务 | 设计复杂度高 | 可调 |
4. 状态与记忆:Agent能不能“记住事”决定了它能不能干长活
4.1 短期上下文不是“把历史全塞进去”
短期上下文最朴素的做法是把所有对话历史拼进prompt。但上下文窗口是有限的,而且token是要花钱的。我见过一个项目,会话跑到第二十轮的时候,每轮prompt已经上万token,成本飙升不说,模型还开始“迷失”在冗长的历史里。
正确的做法是分层管理短期上下文:最近几轮完整保留,稍早的轮次做摘要压缩,更早的只保留关键结论和事实。摘要压缩可以用模型来做,但要注意摘要本身也可能丢失关键细节,所以对于涉及具体参数、ID、金额的信息,要单独抽取成结构化字段保留,不参与压缩。
4.2 长期记忆的写入与召回是两套逻辑
长期记忆解决的是“跨会话记住用户偏好和历史结论”。它通常用一个向量库加一个结构化存储来实现。但很多人只做了“写入”,没做好“召回”。
写入的时候,不是所有对话都值得记。我的策略是按事件类型过滤:用户明确表达的偏好、达成的结论、失败的原因,这些值得记;中间的寒暄、重复确认、临时状态,不值得记。写入时还要带上时间戳和来源会话ID,方便后续追溯。
召回的时候,不是简单做向量相似度检索就完事。向量检索容易召回“语义相似但实际无关”的内容。我的做法是混合检索:向量相似度 + 关键词匹配 + 时间衰减,三者加权排序。时间衰减很重要,三个月前的偏好可能已经过时了。
4.3 状态快照:中断恢复和审计的基石
生产环境里,Agent执行到一半被中断是常态:用户关了页面、服务重启、超时被kill。如果没有状态快照,这个会话就丢了,用户回来得从头开始。
状态快照要记录的东西包括:当前执行到哪个节点、已经完成了哪些子任务、每个子任务的输入输出、当前的上下文摘要、待办事项列表。快照的粒度要权衡:太粗恢复不了,太细存储成本高。我通常在每个“节点完成”时打一个快照,而不是每一步都打。
快照还有一个用途是审计。当用户投诉“Agent做错了”,你需要能回放整个决策链路:它当时看到了什么、想了什么、调了什么、得到了什么。没有快照,这种回放是不可能的。
5. 工具调用的可靠性工程:失败是常态,不是异常
5.1 参数校验要在模型之外再做一层
模型生成的工具参数不可全信。它可能漏字段、类型错误、编造不存在的枚举值。如果直接把模型输出透传给工具,轻则报错,重则写入脏数据。
我的做法是在执行器里加一层参数校验与修复:先用JSON Schema做结构校验,类型不对的尝试转换(比如字符串"123"转数字),枚举值不在范围内的尝试模糊匹配,必填字段缺失的尝试从上下文推断。只有校验和修复都失败,才把错误返回给模型让它重新生成。
这一层看起来多余,但它把大量“本可以避免的失败”挡在了工具之外。实测下来,加了这层之后,工具调用的首次成功率能提升不少。
5.2 重试、熔断与降级的分工
工具调用失败分三种:瞬时失败(网络抖动、限流)、业务失败(参数不对、权限不足)、系统性失败(下游服务挂了)。三种失败的处理方式完全不同。
瞬时失败适合重试,但要加指数退避,避免雪崩。业务失败重试没用,应该把错误信息返回给模型,让它修正参数或换工具。系统性失败要考虑熔断:连续失败达到阈值就暂时跳过这个工具,走降级路径。
降级路径的设计是Runtime成熟度的标志。比如搜索工具挂了,能不能降级到缓存结果?支付工具超时了,能不能先挂起等人工确认?这些降级逻辑不应该写在业务代码里,而应该作为工具的元数据配置在注册中心。
5.3 工具返回结果的“翻译”比调用本身更重要
工具返回的原始结果往往是给机器看的,不是给模型看的。比如一个数据库查询返回几百行JSON,直接塞给模型,既浪费token又干扰判断。
执行器需要做结果翻译:截断过长的结果、提取关键字段、把结构化数据转成自然语言描述、标注数据的置信度和时间范围。这一步做得好不好,直接决定模型下一步决策的质量。我通常会给每个工具配一个“结果格式化器”,定义如何把原始返回转成模型友好的形式。
注意:结果翻译要保留“不确定性信息”。如果工具返回的是估算值或缓存值,一定要在翻译结果里标明,否则模型会当成确定事实使用。
6. 可观测性:看不见的Agent等于不可控的Agent
6.1 追踪链路要细到“每一次模型调用”
Agent的可观测性和普通服务不一样。普通服务一个请求一条链路,Agent一个会话可能包含几十次模型调用和工具调用,每次调用的输入输出都要能追溯。
我习惯用会话ID + 步骤序号作为追踪主键,每次模型调用和工具调用都记录:时间戳、耗时、输入摘要、输出摘要、token消耗、是否命中缓存、错误信息。这些数据汇总起来,才能回答“这个会话为什么慢”“为什么贵”“为什么错”。
6.2 关键指标:不只是成功率
成功率太粗了。我关注的指标包括:平均步数(步数突然升高说明模型在绕路)、工具调用分布(某个工具被异常频繁调用可能是模型理解有偏差)、重试率(高重试率说明工具不稳定或参数校验太严)、上下文压缩比(压缩太狠会丢信息,太松会爆token)、人工介入率(需要人工确认的比例,太高说明自动化程度不够)。
这些指标要能按会话、按用户、按工具、按时间段切片查看。没有切片能力,指标就是摆设。
6.3 回放与调试:让每一次失败都能复现
最痛苦的调试场景是“用户说它错了,但你不知道怎么错的”。回放功能就是解决这个的:把某个会话的完整执行链路重放出来,每一步的输入输出都可见,甚至可以单步执行。
实现回放的前提是前面说的状态快照和追踪日志。回放界面不需要多华丽,但必须能回答三个问题:模型当时看到了什么?它做了什么决策?这个决策导致了什么结果?能回答这三个问题,大部分问题都能定位。
7. 从单机到并发:Runtime的扩展性设计
7.1 会话隔离:别让一个用户的上下文污染另一个
单机跑Demo的时候,全局变量存上下文没问题。一旦并发,必须做会话隔离。每个会话有独立的上下文、独立的记忆、独立的状态快照。隔离的粒度可以是进程级、线程级或协程级,取决于你的并发模型。
我倾向于用会话ID作为所有状态的命名空间,任何存储(内存、Redis、数据库)的key都带上会话ID前缀。这样即使多个会话共享同一个存储实例,也不会串数据。
7.2 并发控制:限流、排队与优先级
Agent的并发不是简单的“来一个请求开一个协程”。模型调用有速率限制,工具调用有下游容量限制,不加控制的话,高峰期会把下游打挂。
我的做法是在Runtime入口加一个调度队列,按优先级排队。优先级可以按用户等级、任务紧急程度、预估成本来定。同时给每个工具配独立的并发信号量,超过容量的请求排队等待而不是直接失败。
7.3 水平扩展时的状态外置
单机Runtime的状态在内存里,水平扩展时就必须把状态外置到共享存储。上下文、记忆、快照、追踪日志,都要能从任意实例读写。
这里有个坑:状态外置会引入延迟。每次读写都要走网络,如果粒度太细,延迟会累积。我的经验是批量读写 + 本地缓存:会话级别的状态在本地缓存一份,定期同步到共享存储;跨会话的长期记忆才实时读写。
8. 我踩过的几个真实坑和对应的解法
8.1 模型“假装”调用了工具
有一次测试发现,模型在回复里说“我已经帮你查了天气,今天是晴天”,但实际上它根本没有发起工具调用,天气是编的。这是因为prompt里工具描述和对话历史混在一起,模型学会了“模仿”工具调用的语气。
解法是在Runtime层做强制校验:如果模型回复里声称做了某个操作,但追踪日志里没有对应的工具调用记录,就判定为幻觉,触发重新生成或直接返回错误。这个校验规则要写在Runtime里,不能指望模型自觉。
8.2 工具返回超长文本导致上下文爆炸
有个工具返回的是网页全文,一次几万token。模型拿到之后,后续每一步都带着这几万token,成本直接失控。
解法是在工具注册中心给每个工具配结果长度上限和截断策略。超长结果要么截断,要么先做摘要再返回。摘要可以用小模型做,成本远低于把全文塞给大模型。
8.3 人工确认环节把状态机搞乱
加人工确认的时候,我一开始是在循环里加了个if判断,结果确认回来后状态对不上,整个流程乱了。后来改成把人工确认也当成一个节点,确认前打快照,确认后从快照恢复继续执行。这样确认环节对主流程是透明的,不会破坏状态机。
8.4 记忆召回把无关信息带进来
长期记忆用向量检索,结果经常召回一些“语义相似但实际无关”的内容,干扰模型判断。后来加了时间衰减和来源过滤:只召回最近一定时间内的、来自可信来源的记忆,并且给每条记忆打一个相关性分数,低于阈值的直接丢弃。
9. 一套可落地的Runtime最小骨架
如果你现在就想动手搭一个,我建议从下面这个最小骨架开始,不要一上来就追求大而全。
第一层:会话管理。用会话ID做命名空间,管理上下文的读写和快照。这一层不涉及任何智能,就是纯粹的状态管理。
第二层:编排引擎。先实现一个最简单的ReAct循环,但把循环的每一步都做成可拦截的钩子。这样后续换编排模式时,不用重写循环。
第三层:工具注册中心。每个工具注册时带上元数据(权限、幂等、超时、降级)。执行器从注册中心取工具,做参数校验和结果翻译。
第四层:追踪与快照。每次模型调用和工具调用都记日志,每个节点完成时打快照。这一层一开始可以简单,但必须有,否则后面没法调试。
第五层:可观测性面板。把追踪日志汇总成指标,能按会话查看执行链路。这一层可以最后做,但没有它,Runtime就是个黑盒。
这五层搭起来,大概几百行代码,但已经具备了生产Runtime的核心骨架。后续的Plan-and-Execute、图编排、长期记忆、并发控制,都是在这个骨架上生长出来的。
提示:不要一开始就引入向量库、消息队列这些重组件。先用最简单的内存存储把链路跑通,确认编排逻辑没问题,再逐步替换存储层。过早引入重组件会让调试变得极其困难。
10. 关于Agent Runtime,我目前的一些判断
做了这么多轮之后,我越来越觉得Agent Runtime的核心竞争力不在“智能”,而在“工程”。模型能力是外部变量,你控制不了;但Runtime的稳定性、可观测性、可扩展性,是你完全可以掌控的。一个模型能力一般但Runtime扎实的系统,长期表现往往好过一个模型很强但Runtime稀烂的系统。
另一个判断是:编排模式会趋同,但状态管理和工具治理会分化。ReAct、Plan-and-Execute、图编排这些模式,最终会沉淀成标准化的组件,大家用起来差不多。但每个业务场景的状态管理需求、工具治理策略、合规审计要求都不一样,这部分才是Runtime真正需要定制的地方。
最后分享一个我自己的习惯:每次Runtime出问题,我都会问自己三个问题——是模型的问题、是工具的问题、还是编排的问题?把问题归类之后,解法就清晰了。模型的问题靠prompt和校验,工具的问题靠元数据和降级,编排的问题靠状态机和快照。这三类问题混在一起,才是真正让人头疼的根源。