回头看我们的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 接进来的外部工具。不管哪种,都走同一条管线:

  1. 参数校验:不符合 schema 的直接打回;
  2. 执行:带超时;
  3. 结果处理:超长截断、空结果填占位文本;
  4. 回传:成功了返回结果,失败了返回失败的原因。

第 4 条是这两个月吃过亏之后加强的。工具失败时,如果只告诉模型"失败了",它要么原样重试,要么编一个结果出来。把原因告诉它(参数错了、超时了、没有权限),它才能做出合理的下一步。

4. L4 安全:多层 fail-closed

单层的防护总会被绕过,所以要叠很多层:

  1. 模型只能输出结构化的请求,不能直接执行;
  2. 工具有白名单,不在名单上的一律拒绝;
  3. 修改类的操作在服务端确认,不靠模型自觉;
  4. 每轮的工具调用次数有上限;
  5. 每次调用有超时,输出有截断。

任何一层被绕过,其他层仍然有效。

这里分享一个很经典的反面例子。有人给自己的 agent 做了一个"只读命令白名单",检查命令的第一个词是不是 cat、ls 这些,然后用 sh -c 去执行。看起来很安全,但 cat a.txt; rm -rf / 的第一个词也是 cat,sh -c 会老老实实地把两条命令都执行掉。

正确的做法是根本不经过 shell 解释器:把命令拆成程序名和参数,直接启动进程,同时拦掉 ;、|、&、`、$ 这些元字符。用 Java 的话说,就是用 ProcessBuilder 传参数数组,而不是拼一个字符串丢给 bash -c。

这个例子还说明了一件事:安全层本身也需要被审查。

5. L5 韧性:重试有上限

模型调用经 LiteLLM 统一接入,它本身支持重试和降级。我们在上面定了几条规矩:

  1. 重试有上限,退避加抖动;
  2. 主模型不可用时,降级到备用模型;
  3. 连续失败达到阈值,就熔断,不再请求,直接给用户一个兜底的回复;
  4. 后台任务(总结、提取记忆)遇到过载直接放弃,不和前台抢资源。

没有上限的重试不是韧性,而是浪费。

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最便宜的模型

不是全局换成小模型,而是按调用点,缩减最贵的那个维度。

五、接下来

底座现在只接了文字输入。接下来要支撑的产品是和硬件结合的,会有语音,会有设备上报的事件,比如戒指、手表的健康数据。

所以下一步是在内核外面加一层输入适配:语音、文字、设备事件,进来时长得各不一样,进了内核之后统一成同一种消息。内核不关心输入从哪来,同一个内核才能支撑不同的产品。

到时候踩了什么坑,再写出来。

总结

  1. Agent 的核心循环只占很小一部分,大量的工作在周边的 harness 上,最根本的原则是"LLM 提需求,代码决定怎么满足";
  2. 六层构建栈:提示词分宪法和运行时、上下文处处有预算、工具走统一管线、安全多层 fail-closed、重试有上限、写业务之前先接 tracing;
  3. 工具失败时要把原因告诉模型,执行命令不要经过 shell 解释器,安全层本身也需要被审查;
  4. trace 的层级就是 Agent Loop 的结构,越晚接 tracing,补起来越痛苦;
  5. 层与层之间有张力,真正难的是组合;辅助路径要按四个旋钮做能力降维,不要每条都长成一个完整的 Agent。

相关资料