造一个 Agent 系统:七条通用原则
这两年「给模型一堆工具让它自己干活」的 demo 遍地都是,但能上生产的没几个。差别不在模型,在工程。
这篇文章不复述某套具体实现,只讲我们在把一个 agent 从 demo 做到上线、又踩了几个月之后,沉淀下来的七条通用原则。它们不依赖你用什么语言、什么模型、什么工具,也不依赖你做的是数字人还是客服。
一、先把七条摆出来
- 能力的所有权必须在服务端------客户端不该是「有哪些能力」的权威。
- 描述能力的东西,必须和交付能力的东西同批发布。
- 在通用组件里做特化,要可判定、零侵入。
- 同步的交互循环放客户端,异步的长链路放服务端。
- 不要为 UI 状态新建表。
- 流程可以外包给客户端,不变式不行。
- 协议演进:宁可开新类型,不要给老类型加字段。
再加一条藏在后面的元原则:测试要覆盖那些单测证明不了的性质。
下面逐条说。
二、原则一:能力的所有权必须在服务端
一个 agent 的「能力面」由两样东西定义:工具表 和告诉模型怎么用工具的系统提示词。
demo 阶段,这两样经常被写在前端------因为改起来快,刷新就能看到效果。但它带来一个必然的后果:你想加一个能力,必须发一次客户端。
更糟的是连锁反应。移动端要等审核,小程序要等发布,老版本用户手里还留着半年前的客户端------你的服务端已经支持某个能力了,但用户看不到、用不了、还被告知「不支持」。
原则是:能力面随服务端演进,客户端只负责「转发」和「渲染」。
判据很简单:如果一件事「改了不需要发客户端」,它就该在服务端。 工具清单、提示词、参数校验、额度规则,全都属于这一类。
一个成立的设计是这样的:客户端把模型返回的工具调用原样转发给一个服务端接口,接口按名字查表执行。客户端根本不需要知道有哪些工具------加一个新工具,它可以一行不改。
三、原则二:描述能力的东西,必须和交付能力的东西同批发布
这是我最想讲的一条,因为它是我们唯一一次真正的线上事故。
提示词是「能力的描述」,工具表是「能力的实现」。 它们如果分处两个仓库、两条发布流水线,就一定会出现中间态:
text
前端已经更新:提示词里写了「支持音频截取」
后端还没部署:工具表里没有这个能力
↓
模型只能按工具表回答 →「平台不支持这个功能」
用户看到的是「你们平台没有这个功能」。而实际上,代码早就写好了,只是没发出去。
这里的关键不是「发版要小心」,而是:这种错配在结构上是必然的。 只要两样东西能被分开部署,它们迟早会被分开部署。你不可能靠流程纪律永远同步两个仓库。
所以修法只有一个:让它们在结构上无法分家。 把提示词搬进服务端,和工具表走同一个入口注入、同一批发布。
推论:任何「A 描述 B」的配对,都要检查它们是不是同一条发布流水线。 提示词和工具表是一对,文档和 API 是一对,客户端的表单校验和服务端的校验规则也是一对------最后一对尤其常见,而且症状更隐蔽(前端拦住了,后端没拦,绕过客户端就是漏洞)。
四、原则三:在通用组件里做特化,要可判定、零侵入
做 agent 的时候,你多半已经有了一个通用的 LLM 代理层:客户端传什么,你转发什么。
agent 的能力注入(提示词、工具表)加在这个代理里,是最省事的选择------但也最容易把它弄脏。通用的东西一旦被特化污染,所有调用方都得跟着陪葬。
原则是两条:
- 可判定 ------是不是目标调用方,必须由一个明确的、结构化的标记决定,而不是猜。用请求里已有的业务字段(比如一个
client标记),别去嗅探内容。 - 零侵入 ------不是目标调用方,一个字段都不能被改动。不是「基本不改」,是不改。
第二条必须用测试锁住。因为「我们记得这个分支只处理 agent」这种约定,人一定会忘:
text
非目标调用方的请求 → 断言:消息数、工具字段、参数,全都不变
这类测试的价值不在覆盖率,在于它把一条口头约定变成了回归约束。 写它只要十分钟。
五、原则四:同步循环放客户端,异步链路放服务端
Agent 的核心是一个循环:请求 → 模型返回工具调用 → 执行 → 结果回灌 → 再请求,直到不再需要工具。
这个循环该放哪?答案取决于它有多长,而不是取决于你更熟悉哪端。
| 放客户端 | 放服务端 | |
|---|---|---|
| 适用 | 秒级、有界(几轮就结束)、必须实时看到流式输出 | 分钟到小时级、需要跨会话存活、需要人确认后继续 |
| 例子 | 「帮我剪一下这段音频」 | 「把这个 PPT 做成一条讲解视频」 |
| 好处 | 复用已有的流式通道,无状态,延迟最低 | 可恢复、可观测、天然多客户端 |
判据可以量化成三个问题:
- 时长------秒级还是分钟级以上?
- 存活------用户关掉页面,它还该继续吗?
- 确认------中间需不需要人点一下头?
三个都偏「长、该继续、需要确认」,就该做成服务端的状态机,而不是继续加厚客户端循环。这时候哪怕循环逻辑再简单,放客户端都是错的------因为客户端关掉就没了。
反过来,一个三秒钟就结束的循环搬到服务端,你就得为它造一条「服务端 → 浏览器」的推送通道,去传「我在调工具了 / 工具返回了」,把本来一条流的东西拆成两条。纯亏。
一个成熟的系统里,这两种形态应该同时存在,而不是二选一。看到「复杂所以在服务端」或者「简单所以在前端」这种说法,就知道还没想清楚。
六、原则五:不要为 UI 状态新建表
任务卡要显示:标题、几个阶段、进度、产物、一个「等用户确认」的按钮。
本能反应是建一张 agent_task 表,加一堆字段。别。 你已经有记录了------那条「这次任务花了多少钱、状态是什么」的记录。UI 状态挂在它身上就行。
放哪?一个可扩展的结构字段(JSON 之类),而不是一堆列。
好处在演进成本:
- 加一种「会报阶段」的工具,只需要把那个结构写对,不用改 DTO、不用给工具接口加方法、不用加列。
- 写入方(工具)和读取方(接口)之间只隔一个键名约定。
代价是必须容错。 结构是弱类型的,格式一定会漂移。所以读取侧解析失败时要降级------退化成「只有状态」,而不是让整个查询报 500。
弱类型不是问题,对弱类型不做容错才是问题。
同一条原则往上还适用:状态要复用已有的词表。 任务状态就用记录已有的那套状态名,别为 UI 另造一套。多一套词表,就多一处映射错的机会。
七、原则六:流程可以外包给客户端,不变式不行
回到那个客户端循环。它看起来像是「业务逻辑跑在客户端」,很多人会本能地不安。但冷静拆开看:
客户端决定的只是「下一步调哪个工具」。 它造不出工具(服务端按名字查表)、越不过权限(服务端校验归属)、伪造不了参数(服务端逐个校验)。所以循环放客户端是安全的------只要真正的边界在服务端。
但有两样东西绝对不能交给客户端:
一是计费相关的规则。 比如「同一次请求里,完全相同的工具调用只执行一次」。这是防重复扣费的幂等要求,写在客户端就是一个可以被绕过的建议。它属于服务端。
二是循环本身的上限。 客户端里的 最多 4 轮 只是个常量,改掉就能无限循环------而每一轮都是真实的模型调用、真实的记录、真实的扣费。
判据:凡是「违反了就涉及钱或安全」的规则,都不属于客户端。 客户端可以驱动流程,但不能拥有不变式。
顺带一条相关的:额度类的不变式最好做成「数学上不可能违反」,而不是「代码里记得检查」。 比如「实际扣费不会超过冻结额」------如果你的冻结额按上限算、结算时释放整笔再加实际值,那么「扣款失败」在算术上就不可能出现。能靠构造消除的错误,不要靠断言去拦。
八、原则七:协议演进,宁可开新类型,不要给老类型加字段
假设你的异步消息格式是:
text
msgType$用户$记录ID$参数1$参数2...
现在要给某个消息加一个参数。最省事的做法是在末尾追加。而消费者的校验通常是这样的:
text
字段数 < 我需要的数量 → 拒绝
注意它只拦「不够」,不拦「多余」。 于是这条消息发到旧版本 的消费者手里,会被静默接受 ------旧消费者忽略多余的字段,然后照旧逻辑做一遍。
结果就是最坏的一类 bug:不报错,但做错事。
用户要 5 秒的片段,拿到的是整段转码。没有任何异常,没有任何告警,直到用户投诉。
所以:要改语义,就开一个新的消息类型。 新类型在旧消费者上会走到「未知类型」------显式失败。失败可以重试、可以告警、可以退款;做错事只能靠用户发现。
这条原则推广开:凡是「旧版本会静默接受并做错」的演进路径,都要改成「旧版本显式失败」。 加字段、改语义、改默认值,全都属于这一类。
代价是老类型的处理器要留着------因为还有存量数据要用它。这很正常,是兼容的成本。
九、藏起来的第八条:重试必须有终点
这条不显眼,但它是资源泄漏的重灾区。
异步系统里,「失败了重试几次」是标配。通常实现成一个余额:重试一次减一,减到零就不再重试。
问题在于:减到零之后,谁负责收尾?
如果没有,就会出现这样的死局:
text
消息发给消费者 → 消费者不认识这个类型 → 不认领
↓
记录永远停在「排队中」
↓
它占用的资源(额度、配额、连接)永远不释放
↓
前端一直在转圈,没有任何人报错
这不是某一种消息类型的问题,而是「有重试上限」这个机制的固有问题。 只要重试有上限,就必须有一条对称的兜底清理:超过时限 + 重试余额耗尽 = 判定为「已被放弃」,收尾、释放资源、给个明确的失败。
判据:任何「靠重试往前推进」的机制,都要问一句「它永远推不动的时候,谁来收尸」。 答案不能是「不会发生」。
十、最后:测试要覆盖那些单测证明不了的性质
有几种性质,单元测试在原理上就证明不了:
- 装配 ------依赖关系有没有成环、有没有两个组件抢同一个标识、bean 名字冲突。这不是逻辑问题,是图的问题,只有真的把系统装起来才验得到。
- 透明性------「我不该动的东西一个字都没动」。这要靠断言不变性,而不是断言结果。
- 不变式------「扣费不可能超过冻结额」这类,最好在构造上成立(见原则六),退而求其次也要有断言守着。
这三类测试有个共同点:它们不测「功能对不对」,它们测「约束有没有被破坏」。 功能坏了用户会告诉你,约束坏了往往没人知道------直到某天以一种很难追的方式爆出来。
十一、结语
把上面七条压成一句:
让能力、描述、规则和版本号都留在服务端;让客户端只负责转发和渲染;让每一次演进都可以被回滚,而不是被静默接受。
Agent 系统里最难的部分从来不是「让模型会调工具」------这个照着文档就能做出来。难的是它上线半年、发了二十个版本、有三四个客户端之后,还能不能保持清醒。
那七条原则,都是在那之后才写下来的。
关键词:AI Agent、LLM、工具调用、Function Calling、系统设计、架构原则、幂等、灰度发布、协议演进、工程实践