跳至主要内容
我把 Agent 当分布式系统来写

我把 Agent 当分布式系统来写

第一个上线的 Agent,第一天就出了问题。它调用库存查询接口,接口返回 200,内容是空数组。模型把“查不到”理解成“没有限制”,接着往下推荐了一堆不该卖的东西。

代码没有报错,日志干干净净,链路是通的。

从那次起我就不太相信“跑通”这个词了。Agent 和普通服务最大的差别是,它的失败通常不以异常的形式出现。

规划和执行要分开写

ReAct 的好处是每一步都能根据观察调整,坏处是它没有全局观。步骤一多,模型会在局部反复试探,同一个工具同一个参数连着调,观察结果一样,它就再调一次。

靠 prompt 说“不要重复”基本没用。真正管用的是工程手段:硬性步数上限、检测连续相同的动作并注入一条系统提示改变策略、给每个子任务设超时。这些都是笨办法,但它们提供确定性。检测重复也不能只比字符串,参数里带时间戳或者随机 ID 的话,每次调用看起来都不一样,实际却是同一个动作。我一般会把参数归一化之后再比对。

Plan-and-Solve 换了个方向:先生成完整计划,再批量执行。它省 Token,也不会在中途跑偏,代价是计划错了以后不容易回头。适合步骤明确、依赖关系清楚的任务,比如按固定模板生成报告,或者把一份结构化数据转成多种格式。

Tree of Thoughts 我只在离线场景用过。每一步展开多个分支再评估,成本高得离谱,延迟也不适合面向用户的产品。它在解谜类任务上确实有效,但那类任务本来就不需要实时响应。

实际系统里我通常混着用:顶层用大模型做规划,只输出结构化的步骤列表;执行层每一步用更小的模型或直接用代码。让模型负责判断,让代码负责计算和格式转换。

我曾经把一个“帮忙订完整行程”的需求直接交给单个 Agent,结果它在预算和偏好之间反复横跳,前一步刚定的酒店被后一步的交通安排推翻。问题不在模型能力,在这个任务本身需要先固定约束再展开细节。后来改成先让规划层产出约束清单,执行层再逐项填,稳定性立刻不一样。

任务拆得对不对,比模型选得对不对更重要。拆完的每一步应该有一个明确的完成条件,否则 Agent 无法判断该不该进入下一步,只能一直试。完成条件最好是可检查的:字段存在、数值在范围内、返回了指定数量的结果。

中间状态要能被看见。规划一长,用户只能等一个最终结果,出错时也不知道卡在哪。我倾向于把步骤状态推给前端,哪怕只是“正在核对库存”,体验和可调试性都会好很多。这算工程成本,但比事后猜划算。

能用代码算的,不要交给模型心算。

这条几乎是我做 Agent 的第一原则。金额、日期差、排序、字段映射,全部走确定性代码。模型只决定调哪个函数、传什么参数。心算看起来聪明,但它错的时候没有任何提示,而且错得很稳定,测试也不容易覆盖。

规划层的输出最好是可校验的结构,而不是一段自然语言。结构不对就直接重试,不要让它带着一个含糊的计划往下走。计划里的每一步也要声明依赖,哪些可以并行,哪些必须等待,这样执行层才有依据做调度。

代价是前期设计变重。写一个能自由发挥的 Agent 很快,写一个规划、执行、校验分离的系统慢得多。但只要任务会重复出现,后面省下的调试时间远超这点投入。

多 Agent 是为了隔离上下文

单 Agent 撑不住复杂任务,通常不是因为它不够聪明,是因为上下文里塞了太多互相冲突的指令。写代码的和审代码的放在同一个上下文里,模型很难真正挑自己的错。

拆开之后,每个 Agent 有自己的系统提示、自己的工具集、自己的上下文窗口。写代码的只管写,审代码的只管挑问题,审完把结论回给编排层。这跟微服务拆分的动机很像:不是为了先进,是为了让每一块可以独立改动和独立评估。

上下文隔离要彻底。主控把子任务交给执行者时,只传必要的输入,不要把整段对话历史一起带过去。带得越多,子 Agent 越容易被无关信息带偏,而且每多一个执行者,Token 成本就多一份。

代价也很实在。Token 消耗翻倍,链路变长,调试难度上一个台阶,还要处理 Agent 之间状态不一致的问题。两个 Agent 对同一份数据的理解不同,最后汇总出来的结果会自相矛盾,而且很难判断是谁错了。

所以我会先问:这个任务真的需要多个角色吗。如果只是上下文有点长,先试试做摘要、裁掉无关历史;如果只是工具太多,先试试分组和路由。只有当不同部分需要互相冲突的指令,或者需要不同的权限边界时,拆分才是值得的。为了架构好看而拆,最后维护的人会很痛苦。

编排我倾向主从结构。一个主控负责理解需求、拆任务、合并结果,多个执行者并行干活。执行者之间不直接通信,所有信息通过编排层流转。这样链路的拓扑是清晰的,出问题能定位到某一次调用。让 Agent 互相自由对话看起来很灵活,实际很难复现,也很难保证收敛。

路由是另一处容易做糙的地方。用大模型做意图分类准确,但每次请求都要多一次推理;用向量匹配能力描述延迟低,但对模糊意图不敏感。我的做法是先用向量粗筛,置信度不够的再交给小模型判断,两个都不确定就追问,而不是猜一个。

路由错了,后面所有步骤都是错的,而且错得很合理。

这句话是我在一次线上事故之后记下来的。Agent 很认真地完成了用户没让它做的事。

状态管理上,线性链条很快就会不够用。真实的协作有循环:写完、审、改、再审。用图结构表达更自然,共享一个状态对象,条件边决定下一步去哪。这也让“最大轮次”这种熔断变得容易实现,因为它本身就是图上的一个终止条件。

拆分的粒度也要克制。见过把任务切成十几个角色的方案,每个角色只做一句话的事,最后编排的成本比任务本身还高。我现在的标准是:只有当上下文确实冲突、或者需要不同的工具权限时,才拆。

工具定义就是接口设计

Function Calling 的稳定性,八成取决于工具怎么定义,而不是模型多强。

函数描述是 prompt 的一部分。写“搜索数据”等于没写,写“按用户 ID 查询最近三十天的订单,不含已取消”才能让模型判断该不该调。参数用强类型约束,能拦掉大部分非法输入。数值范围、日期格式、枚举取值,都放在 schema 里,不要指望模型记住。

工具要原子化。一个接受二十个参数的万能工具,出错概率远高于三个各接受五个参数的小工具。枚举值在描述里列全,代码层再做一次大小写和空格的模糊匹配,因为模型输出的枚举经常带引号或者大小写不一致。

工具数量也要控制。一次给模型几十个工具,它会在相似的几个之间犹豫,或者干脆选一个看起来最通用的。我的经验是单轮暴露的工具控制在十个以内,其余按场景分组,由路由决定加载哪一组。这既是准确率问题,也是上下文成本问题。

并行调用是另一个容易被忽略的优化。几个工具之间没有依赖时,让模型一次输出多个调用,系统并发执行,端到端延迟能从累加变成取最大值。前提是这些工具要幂等,或者至少能接受重复执行,否则重试一次就产生两份副作用。

返回值要带状态。只返回数据,模型无法判断这一步是否真的成功;返回“成功但有 0 条结果”,它才知道该换策略。这条设计比多数 prompt 技巧都有效,因为它把“查无结果”和“查询失败”区分开了。

出错之后不要把异常直接抛给用户。把错误信息转成自然语言,作为观察结果回给模型,让它自己改参数重试。这条反思回路在参数格式错误上特别有效,比如日期格式不对,模型看到报错基本能一次改对。

但这条回路必须有限。最大重试次数是硬性的,超过就终止。否则一个不稳定的接口能让 Agent 在一个循环里烧掉大量 Token。

错误信息还要清洗。完整的堆栈又长又包含路径,塞回去既占上下文又分散注意力。只保留错误类型和关键提示,效果反而更好。

错误要分类。参数非法、权限不足、资源不存在,这三类重试没有意义,应该直接改变策略;超时、限流、连接中断才值得退避重试。混在一起处理,会让 Agent 在注定失败的调用上反复消耗。

工具不可用的时候,除了报错,还要明确告诉模型“这个工具当前不可用”,否则它会换个参数再试一次。降级也要提前设计:主搜索接口挂了,切备用源还是直接走知识库,这个决策最好写在配置里,而不是留给模型临场发挥。

安全边界是我最不愿意妥协的一块。写操作、支付、删除这类工具,必须有人确认,或者至少有一条独立的权限校验,不能只靠 prompt 里的“请谨慎”。提示注入的防护也在这个层面做:外部内容进入上下文时标记来源,工具参数在执行前做白名单校验,不要让模型直接拼 SQL 或者命令行。

记忆和运维才是长期成本

把历史对话全塞进向量库,是最常见的偷懒做法,也是效果最差的一种。

向量检索擅长语义联想,不擅长精确状态。用户的会员等级、当前订单号、上次说的过敏源,这些是需要覆盖更新的字段,不是需要相似度匹配的文本。向量库是追加写入的,用户改口之后,旧信息仍然留在召回池里,模型无法判断哪条是当前状态。

我现在的分层是这样:最近几轮对话用滑动窗口原样保留,保证指代能解析;更早的内容定期压缩成摘要,接受细节丢失;而关键事实走结构化存储,用提取侧链在对话过程中更新。

摘要本身也会产生幻觉,压缩时可能把“可能”写成“确定”。所以摘要只用于背景回顾,不作为事实依据。

结构化记忆的价值在需要硬约束的场景特别明显。用户一个月前提过一句过敏,推荐的时候必须能命中。这种约束用向量相似度去碰运气不合适,查一次结构化记录的成本几乎为零。

代价是要维护一套抽取逻辑,还要处理冲突:用户先说喜欢咖啡,后来说戒了,谁覆盖谁,时间戳怎么定。这块我还在摸索,目前只用“后写的覆盖先写的”这一条简单规则,偶尔会出错。

记忆还要有遗忘。什么该记、记多久、什么时候主动删掉,现在基本靠拍。记太多会污染上下文,记太少用户会觉得它不长记性。这个问题不像工程问题,更像产品问题,我暂时没有答案。

检索记忆的时机也要设计。不是每轮都查一遍向量库,那会让延迟变高,还会引入无关的历史片段。我的做法是分两类:涉及用户偏好和状态的,每轮都查结构化存储,成本很低;涉及“以前聊过什么”的,只在用户明确回顾或者话题切换时查一次。

上线之后,真正花时间的不是让它更聪明,是让它可观测。

一次请求可能有十几次模型调用和工具调用,没有 trace 就只能靠猜。我要看到的是每一步的输入、输出、耗时、Token 数,以及工具返回的原始内容。尤其是工具返回,很多“模型胡说”最后查出来是接口返回了脏数据。

指标我只看几个:端到端延迟、单次会话成本、工具错误率、循环次数、需要人工接管的比例。最后一个最能说明问题,它直接对应产品能不能规模化。

成本要有硬熔断。单次会话的 Token 上限、单用户每日预算、单个任务的最大步数,这三个阈值必须在代码里,不在 prompt 里。Agent 陷入循环的时候,模型自己是察觉不到的,它能看到的只有“还没有完成任务”。等账单出来再发现,已经晚了。

评估按三层做。最底层是确定性检查,格式对不对、字段全不全,跑得最快;中间用模型打分,看任务是否完成、工具选得对不对;最上层是人工抽检和线上指标。前两层进持续集成,第三层按周做。

最麻烦的是评估用例本身。Agent 的执行路径不止一条,同一个任务可以走不同的工具组合,全都正确。按固定路径比对会把正确行为判成错误,只能退一步,看最终状态和关键约束有没有满足。这件事我还没找到特别干净的解法。

发布要灰度。模型的非确定性意味着同一份代码在不同流量上表现不同,全量切换之后出问题,回滚也是回滚到另一个不确定版本。我的做法是一部分流量走新版本,盯着人工接管率和工具错误率,两项都稳住再放大。

日志里要留足现场。模型的输入输出、工具的原始返回、当时的提示词版本,至少要能按请求 ID 还原一份完整快照。缺了任何一项,复现就无从谈起,只能靠猜。快照也要设保留期,不然存储成本会先撑不住。保留期按业务风险定,涉及资金和合规的留久一点。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Agent #工具调用 #工程实践 #记忆

评论