回头看我们的Agent底座——六层构建栈:提示词、上下文、工具、安全、韧性、可观测
AI 小组成立快两个月了,我们的 Agent 底座终于有了个样子:一个内核,上面能挂不同的业务。
这两个月基本是边做边想,设计散落在各处:提示词怎么分层是一次讨论定的,重试策略是一次线上告警之后补的,tracing 是某天排查问题排到崩溃时决定接的。
趁着第一款产品上线前的空档,我把这些零零碎碎的设计按层整理了一遍,顺便检查一下有没有漏掉的地方。
一、Agent 不只是"调一下模型"
刚开始做的时候,很容易把 Agent 理解成"调一下模型,再执行一下工具"。真做下来才发现,调模型那几行代码只占很小的一部分,大量的工作都在周边:上下文怎么组织、工具怎么执行、出错了怎么办、出了问题怎么查。
最近开始有人把这一圈周边工程叫作 harness,直译是"马具"。模型是马,力气很大,但不知道往哪跑;harness 就是套在它身上的那一整套东西,决定了这股力气往哪使、使多大、什么时候停。
这一层有一个最根本的原则:LLM 提需求,代码决定怎么满足。
模型不直接执行任何东西,它只是输出一个结构化的"我想做什么",由我们的代码去校验、决定执不执行、怎么执行。所有的安全、预算、熔断,都建立在这个前提之上。如果把执行权交给模型,后面这些就无从谈起。
我参考的是一个六层的构建栈,下面逐层对照一下我们的底座。
二、六层构建栈
| 层 | 管什么 | 核心原则 |
|---|---|---|
| L1 提示词 | 模型该怎么想 | 稳定的宪法 + 动态的运行时 |
| L2 上下文 | 模型能看到什么 | 为一切设定预算,截断要告知 |
| L3 工具 | 模型能做什么 | 模型提需求,代码负责执行 |
| L4 安全 | 什么绝对不能做 | 多层 fail-closed |
| L5 韧性 | 出错了怎么办 | 有上限的重试、熔断、降级 |
| L6 可观测 | 出了问题怎么查 | 写业务之前先接 tracing |
1. L1 提示词:宪法与运行时分开
提示词拆成两部分:
- 宪法:人格、说话风格、安全底线、输出格式。对所有用户都一样,进版本控制,放在请求的最前面;
- 运行时:当前用户是谁、现在是什么时间、最近发生了什么。每次请求都可能不一样,统一注入到请求的尾部。
这样做一举两得:宪法稳定在前面,能命中前缀缓存;运行时集中在一个地方注入,出了问题也好查。这块在之前写 Prompt Cache 的那篇里展开过。
2. L2 上下文:每个来源都有预算
人格、记忆、知识库检索、更早对话的摘要、最近的对话,每个来源各有一个上限,再加一个总量上限。截断的时候注入一句元信息,告诉模型"以上是部分内容,共有多少"。
这块上个月单独写过一篇,这里不展开了。
3. L3 工具:一条统一的管线
我们的工具有两种来源:自己写的强类型 function calling,以及通过 MCP 接进来的外部工具。不管哪种,都走同一条管线:
- 参数校验:不符合 schema 的直接打回;
- 执行:带超时;
- 结果处理:超长截断、空结果填占位文本;
- 回传:成功了返回结果,失败了返回失败的原因。
第 4 条是这两个月吃过亏之后加强的。工具失败时,如果只告诉模型"失败了",它要么原样重试,要么编一个结果出来。把原因告诉它(参数错了、超时了、没有权限),它才能做出合理的下一步。
4. L4 安全:多层 fail-closed
单层的防护总会被绕过,所以要叠很多层:
- 模型只能输出结构化的请求,不能直接执行;
- 工具有白名单,不在名单上的一律拒绝;
- 修改类的操作在服务端确认,不靠模型自觉;
- 每轮的工具调用次数有上限;
- 每次调用有超时,输出有截断。
任何一层被绕过,其他层仍然有效。
这里分享一个很经典的反面例子。有人给自己的 agent 做了一个"只读命令白名单",检查命令的第一个词是不是 cat、ls 这些,然后用 sh -c 去执行。看起来很安全,但 cat a.txt; rm -rf / 的第一个词也是 cat,sh -c 会老老实实地把两条命令都执行掉。
正确的做法是根本不经过 shell 解释器:把命令拆成程序名和参数,直接启动进程,同时拦掉 ;、|、&、`、$ 这些元字符。用 Java 的话说,就是用 ProcessBuilder 传参数数组,而不是拼一个字符串丢给 bash -c。
这个例子还说明了一件事:安全层本身也需要被审查。
5. L5 韧性:重试有上限
模型调用经 LiteLLM 统一接入,它本身支持重试和降级。我们在上面定了几条规矩:
- 重试有上限,退避加抖动;
- 主模型不可用时,降级到备用模型;
- 连续失败达到阈值,就熔断,不再请求,直接给用户一个兜底的回复;
- 后台任务(总结、提取记忆)遇到过载直接放弃,不和前台抢资源。
没有上限的重试不是韧性,而是浪费。
6. L6 可观测:写业务之前先接 tracing
这一层是我最想说的。
一开始我们只打日志,出了问题就去翻。但 Agent 的问题往往不是"报错了",而是"回答得不对"。为什么不对?是记忆召回错了,是检索结果不相关,是提示词没生效,还是模型自己理解偏了?光看日志根本分不清。
后来接了 Langfuse,一个关键的决定是:trace 的层级就是 Agent Loop 的结构。
from langfuse import observe
@observe(name="turn")
async def run_turn(session: Session, user_input: str) -> str:
memories = await recall_memories(session, user_input) # 一个 span:召回
messages = build_messages(session, memories, user_input)
while True:
response = await call_model(messages) # 一个 span:模型调用
if not response.tool_calls:
return response.text
results = await run_tools(response.tool_calls) # 一个 span:工具执行
messages.extend(results)
@observe(name="recall")
async def recall_memories(session: Session, query: str) -> list[Memory]: ...
@observe(name="llm", as_type="generation")
async def call_model(messages: list[dict]) -> ModelResponse: ...
可以看到,一轮对话是一个 trace,里面的召回、每一次模型调用、每一次工具执行都是一个 span,和循环的结构一一对应。线上有一条回答不对,点开 trace,就能看到这一轮召回了哪些记忆、发给模型的完整请求是什么、模型返回了什么、调了哪个工具、工具返回了什么。
先画出循环,再设计 span。在写第一行业务逻辑之前,先接入 tracing。 这句话我现在深有体会,越晚接,补起来越痛苦。
三、层与层之间的张力
六层不是简单地往上堆,有些层之间是互补的,有些是有张力的。
互补的,比如上下文预算和"截断要告知":预算让内容变少了,元信息让模型知道自己看到的不全。比如 fail-closed 和渐进式授权:默认什么都不许,确认过了再一点点放开。
有张力的,最典型的是预算和缓存。为了守住预算,要截断一些内容;可截断的位置如果在请求的前面,就会破坏前缀缓存。我们的做法是,会被截断的内容尽量放在请求的后半段,并且截断的决定一旦做出,后续的请求就沿用同一个截断版本,不要每一轮重新算。
真正难的不是理解单个模式,而是把它们组合起来,还不互相打架。
四、能力降维:别让每条路径都长成一个完整的 Agent
整理的时候还发现一个问题:我们有好几条"辅助路径",比如给会话起标题、做会话摘要、提取记忆,一开始都是照着主对话的方式写的,用主模型、带完整的工具、跑多轮循环。
完全没必要。后来按四个旋钮重新裁剪了一遍:
| 路径 | 上下文 | 工具 | 回合数 | 模型 |
|---|---|---|---|---|
| 主对话 | 完整 | 完整 | 多轮 | 主模型 |
| 记忆提取 | 最近一轮 | 只能写记忆 | 少量 | 便宜的模型 |
| 会话摘要 | 待摘要的部分 | 无 | 1 | 便宜的模型 |
| 起标题 | 很小 | 无 | 1 | 最便宜的模型 |
不是全局换成小模型,而是按调用点,缩减最贵的那个维度。
五、接下来
底座现在只接了文字输入。接下来要支撑的产品是和硬件结合的,会有语音,会有设备上报的事件,比如戒指、手表的健康数据。
所以下一步是在内核外面加一层输入适配:语音、文字、设备事件,进来时长得各不一样,进了内核之后统一成同一种消息。内核不关心输入从哪来,同一个内核才能支撑不同的产品。
到时候踩了什么坑,再写出来。
总结
- Agent 的核心循环只占很小一部分,大量的工作在周边的 harness 上,最根本的原则是"LLM 提需求,代码决定怎么满足";
- 六层构建栈:提示词分宪法和运行时、上下文处处有预算、工具走统一管线、安全多层 fail-closed、重试有上限、写业务之前先接 tracing;
- 工具失败时要把原因告诉模型,执行命令不要经过 shell 解释器,安全层本身也需要被审查;
- trace 的层级就是 Agent Loop 的结构,越晚接 tracing,补起来越痛苦;
- 层与层之间有张力,真正难的是组合;辅助路径要按四个旋钮做能力降维,不要每条都长成一个完整的 Agent。