我第一次把业务逻辑交给 Agent 跑通,用了大概两天。第一次让它连着跑三十天不出事,用了快两个月,而且最后靠的不是更好的提示词,是把大部分决策从模型手里收回来,改成显式的流程。
那两个月里我改掉的,主要不是代码,是我对“自动化”的理解。
单体 Agent 撑不住长流程
最初的想法很自然:一个足够强的模型,挂一堆工具,让它自己决定调哪个。简单,优雅,演示效果也好。
跑到第五个步骤开始出问题。上下文里堆了十几轮工具返回的结果,模型开始漏看前面的约束,同一个参数反复试,或者干脆忘掉最初的任务目标。这不是模型能力的问题,是上下文本身有容量,任何模型都有。
拆成多个角色之后情况好转。不是因为每个子 Agent 更强,而是因为它们各自只需要关心一小段。规划的那个只看目标和可用能力,执行的那个只看自己那几个工具。上下文短了,指令遵循就稳。
另一个更实际的动机是工具太多。一个 Agent 挂五十个工具,调用准确率会明显下降,它会在相似的工具之间反复选错。按领域拆开,每个角色只面对三五个工具,错误率立刻下来。
这里的取舍是复杂度。单体方案只有一个进程、一份日志、一处配置;多智能体要处理消息传递、状态共享、失败重试、跨角色的一致性。为了稳定性付出的代价是可观测性变差,出了问题得顺着链路一段段看。我接过一个线上问题,最后定位到是两个角色对“完成”的定义不一致,前后翻了半小时日志。
再往下是中心化还是去中心化的选择。前者有一个明确的调度者,知道全流程走到哪一步,谁该干什么;后者让角色之间通过消息自行触发,谁也不管全局。
我基本只用前者。原因很实际:企业流程要的是可追溯,出问题能说清是在第几步、由谁决定的。中心化能给出这条线,日志集中,状态集中,回滚也有地方下手。代价是调度者成了单点,它挂了整个流程就停,所以它本身要做得极其简单,最好只做路由,不承载业务逻辑。
去中心化的好处是某个角色失效不一定拖垮全局,适合那种步骤之间本来就松散的场景,比如一堆独立的分析任务并行跑。但它的可观测性很差,两个角色互相等待的时候,很难看出卡在哪儿。我在这上面吃过亏,后来给它加了一个旁路观察者专门盯消息,实际上又把中心化加了回来。
状态图比链式调用更接近业务
链式调用的问题是它没有回头路。第一步到第二步到第三步,中间任何一步失败,整条链就断了,或者只能把错误带到下一步,让它带着错误继续跑。
真实业务不是这样的。真实业务里,写错了要重写,查不到要换关键词,用户中途改需求要能回到上一步。这些都需要循环,需要条件分支,需要在任意一个点上暂停。
所以我后来基本都在用状态图。节点是执行单元,边是流转条件,整个流程有一份明确的状态对象在流转。这份状态里放三类东西:消息历史、控制标记(比如重试次数、上一次的错误)、以及正在构建的产物。
1 | class AgentState(TypedDict): |
写成代码之后有个直接好处:出错的地方能看见。链式调用报错,只知道这一步挂了;状态图报错,能看到当时整个状态是什么,重放一遍就能复现。调试从猜变成了看。
代价是前期设计成本高。画一张状态图要想清楚所有分支,包括那些“正常情况下不会发生”的分支。想不清楚就上线,最后一定会在某个分支上卡死。
它也解决了一件我原先没预料到的事:可观测。每次流转都留下一条记录,哪个节点、输入什么、输出了什么、耗时多少,串起来就是一条完整的执行轨迹。排查的时候不用再问模型“你刚才为什么那么做”,直接看轨迹就知道它走错了哪一步。这个价值在系统变复杂之后才显现,单体方案里几乎用不到。
也正因为有轨迹,才敢让它循环。没有记录的循环等于在黑箱里转,转几圈都没人知道。有记录的话,最坏情况也只是多花一点钱,然后把那段轨迹拿出来改。
工具接口是新的抽象层
我做全栈的时候,接口设计是本能:一个函数该收什么参数、返回什么、出错怎么表示。现在这件事换了个形态,变成了“模型怎么理解这个工具”。
同一个功能,工具描述写得含糊,模型就会在错误的时候调它,或者传一堆看起来合理但业务上无效的参数。这不是模型的错,是接口没设计好。名称、描述、参数约束、什么情况下不该用,这些都要写清楚,跟给人写 API 文档一样,甚至更严格,因为读文档的人不会提问。
MCP 这类协议的价值就在这里。它把工具的实现和模型的调用解开了:工具跑在一个独立的 server 上,通过标准协议暴露能力和权限,模型那边只认协议。换模型不用重写工具,换工具也不用改提示词。
我更看重的是权限这一层。把整个数据库的连接串交给 Agent 是危险的,通过协议只暴露几个受限的查询,风险就小得多。这个思路跟后端的服务边界是一样的,只是现在被服务方从一个服务变成了一个概率性的调用者。
传输方式也有取舍。本地跑用 stdio,简单、低延迟、没有网络暴露面;远程或者多机部署走 SSE,但要多考虑认证和超时。我倾向默认本地,只有确实需要共享的时候才往外放。
检索其实也是编排的一部分,而且是最容易被当成“配好了就不用管”的那部分。纯向量检索在处理精确匹配时很弱,产品型号、函数名、订单号这类东西,语义相似度帮不上忙。所以实际能用的方案基本都要混一路关键词检索,再对合并后的结果重排一遍。
另一个细节是切片的粒度。按内容匹配时应该用小块,命中更准;交给模型的应该是这一块所属的更大段落,否则它会拿着半句话去推理。这个父子结构不复杂,但少了它,答案经常在“看起来对”和“根本不对”之间摇摆。
再往上一层是让模型自己判断该不该检索。有些问题凭已有上下文就能答,硬去检索反而引入无关材料,把原本清楚的推理搅乱。我的做法是加一个判断节点:先评估现有信息够不够,不够才去查;查回来之后再看相关性,差就改写查询重试,而不是硬着头皮生成。
检索冲突是另一个绕不开的情况。两份材料说法相反时,让模型自己选一个是最糟的处理,它选得毫无理由。更合理的是按时间和来源加权,或者在分析类任务里如实呈现分歧——用户需要知道这件事本身有争议,而不是要一个被抹平的答案。
该停的时候要能停下来
这是我觉得最能区分“做过 Demo”和“上过线”的地方。
死循环几乎一定会发生。模型调一个接口,参数格式不对,报错,它认为下次会成功,用同样的参数再试一次,直到把上下文耗完。整个过程它每一步都很自信。
所以硬限制必须有:最大步数、最大重试次数、单次会话的 token 预算。触发就直接终止,不要指望模型自己想明白。
比硬限制更好用的是把错误翻译成人话再喂回去。直接抛一个 500 或者一段堆栈,模型大概率读不懂;换成“上次失败是因为日期必须是未来时间,请修改参数后重试”,它自己修对的概率相当高。这个改动很小,效果差别很大。
再往上一层是监督。单独跑一个轻量的检查节点,不干具体活,只看执行轨迹:连续两步调用同样的工具、用同样的参数,就介入,注入一句“这条路径已经失败过,换一种方式或者向用户确认”。它不需要多聪明,规则占多数也能拦住大部分死循环。
人的介入点则要提前定好,不能等出事再补。我自己会分三类:只读操作直接执行;有副作用但可撤销的,执行后给人看一份摘要;不可撤销的,比如转账、发邮件、删数据,执行前必须停下来等确认。
停下来这件事有个容易被忽略的工程前提:状态要能持久化。人可能半小时后才回来点同意,这期间进程不能挂着,服务重启也不能把待审批的任务弄丢。所以状态得落库,恢复的时候从断点继续。听起来是基础设施问题,实际它决定了这个功能能不能真的用。
还有一类是修正。人看到中间结果不对,希望能改一下再让它继续,而不是从头再来一遍。这要求每一步的状态都有快照,能回到任意一个点重新往下走。做起来麻烦,但它是“人在回路里”和“人在旁边看着”的区别。
还有一种情况是它答得太顺,看不出它在编。这时候需要的是外部的检查:检索结果和答案对不对得上,关键数字有没有出处,两个来源冲突的时候是加权还是如实呈现分歧。这些都属于编排层的判断,不该交给生成的那一步。
企业级系统要的往往不是百分之百的自动化,是百分之百的可控。
我现在的判断是,这个岗位的核心能力不在会不会用某个框架。框架一年换一批,LangGraph 之后还会有别的。真正留下来的东西是:知道哪一步该让模型决定,哪一步必须写死,以及出错的时候系统往哪儿退。
这份工作里最有价值的动作,往往是把一部分权限收回去。收得越早,后面越省心。模型的能力会一直涨,但它永远不知道自己该不该停。
这个判断力没法从文档里抄。只能自己上线几次,被自己的 Agent 坑几次,然后记住那种感觉。记住之后,下一次设计时手会抖得早一点,而那个“早一点”就是经验。
评论