1. 常驻 Agent 翻车实录:没有分层之前,我经历了什么
做了快两年常驻 Agent,我最大的感受是:它不像是"写一个程序",更像是在"养一个会自主行动的员工"。普通程序崩了会告诉你在哪一行,常驻 Agent 崩了往往是因为它在某个凌晨三点自己决定调了一个你没写过的工具。我最早的项目就是一句话的 while True 循环加 prompt,模型记不住上次做了什么,工具返回格式变了直接带崩整个会话栈,prompt 改完上线想回滚,连之前那版写的是什么都找不到。折腾了几轮之后,我开始怀疑问题不在模型智障,而在运行底座。
后来我把常驻 Agent 拆成了四层——声明层、校验层、版本层、预演层,并且用 72 条命令把每一层到底"给什么保证"全部实测了一遍。这篇文章就是那份实测记录,以及踩坑之后我才想明白的分层逻辑。如果你正在做 AI Agent 开发,尤其是 Agent 要常驻运行、对接外部工具、自动执行任务的项目,这篇文章应该能帮你少走不少弯路。
1.1 三个真实的翻车点
第一个翻车点:不可回滚。有一次我在线上顺手改了一版 prompt,改完之后效果确实变好了,但三天后出现了一个诡异的行为——Agent 开始频繁给同一个用户发相似内容。我第一反应是"把 prompt 改回去",结果发现我根本没留备份。我甚至想不起来上一版 prompt 写了什么,因为它是直接写在代码字符串里的,提交代码的时候也没有单独标记。那一次我被迫停服半天,靠 git 历史倒推才找回改动。常驻 Agent 的可变资产实在太多了,prompt、工具描述、技能包、知识库快照,任何一个都可能是行为突变的根源,不版本化等于裸奔。
第二个翻车点:输出不稳定。我的 Agent 依赖一个第三方工单系统,它的 API 接口那天突然多返回了一个字段。模型收到这个陌生字段之后,开始胡言乱语,把工单优先级全部标记成"低"。更麻烦的是,这个错误结果没有触发任何异常,因为格式上它仍然是合法的 JSON。大家总说 AI 输出不稳定,但常驻场景里真正不稳定的不只是模型输出,还有工具返回、缓存数据、记忆状态。这些数据一旦进了 Agent 的状态机,就会像病毒一样扩散。
第三个翻车点:越权调用。某天深夜,Agent 在某个分支里调用了一个我根本没打算让它用的内部 API。它不是被攻击了,而是被 prompt 拐带了。当时的 prompt 里有一句"为了完成任务,你可以使用一切可用的接口",模型就真的去探测并调用了那个内部接口。常驻 Agent 的运行时间是小时级别、天级别,随机性会被时间无限放大。一个偶发的 prompt 解释偏差,在普通对话里只是答非所问,在常驻 Agent 里就是一次真实的线上事故。
1.2 四层不是功能拆分,是保证拆分
踩完这些坑之后我想明白了一件事:分层的本质不是把代码拆成几个模块,而是把"运行保证"拆开。声明层保证"能力可审计",校验层保证"数据不出格",版本层保证"改动可回滚",预演层保证"变化可验证"。
这四个保证分别对应了我前面四个翻车点:越权调用归声明层管,输出不稳定归校验层管,不可回滚归版本层管,而预演层是用来防止新改动引入回归的。很多人讨论 harness 和 agent 的区别,我觉得最直白的理解是:agent 负责自由发挥,harness 负责给这四类保证兜底。你的 Agent 可以很聪明,但承载它的轨道必须是确定的。
1.3 72 条命令是怎么实测的
为了让结论可复现,我改造了一个早期的 Agent 原型,给每一层封装了命令入口。整套验证共 72 条命令:声明层 12 条、校验层 16 条、版本层 22 条、预演层 18 条,外加 4 条横切冒烟命令。每一条命令都指向一个具体的失败场景,不是随机冒烟。后面每一章我会给出代表性命令、实测结果,以及这一层真正能保证什么、不能保证什么。
2. 声明层给什么保证:能力边界和权限变得可审计
2.1 声明层在声明什么
声明层的核心是把 Agent 的"能力边界"从代码里抽出来,变成一份显式的、可阅读、可比较的声明文件。声明内容包括四个部分:
- 能力集合:这个 Agent 能调用哪些工具,不能调用哪些工具。
- 权限集合:每个工具允许操作的资源范围,比如只读还是可写、限定哪些项目、哪些用户。
- 行为约束:哪些动作是明确禁止的,比如禁止删除、禁止发送消息、禁止访问外部网络。
- 知识边界:数据来源范围,包括知识库白名单、可读取的文档目录。
早期我图省事,直接在代码里写tools = [search, send_email, ...],然后让 Agent 自己去翻工具列表。这种做法最大的问题是:工具的可见性和可调用性完全绑在代码里,你没法在运行前回答"这个 Agent 到底能干什么"。
我现在的做法是把声明写成一个 YAML 文件,Agent 启动时只从这个文件加载工具和权限配置。文件长这样:
agent: name: customer-support description: 自动处理客户工单,仅支持查询和标准回复 tools: - name: ticket.query permissions: [read] scope: project_id:demo - name: ticket.reply permissions: [write] scope: project_id:demo - name: internal.api.invoke enabled: false constraints: forbidden_actions: - ticket.delete - user.impersonate这段声明的意思很明确:这个 Agent 只能读工单、写工单回复,而且只限定在 demo 项目里。internal.api.invoke 被显式设为禁用,ticket.delete 出现在禁止动作清单里。只要运行时严格遵守这份声明,越权调用这类事故就能从根上掐掉。
2.2 12 条命令实测:声明层到底保证了什么
我给声明层封装的命令路径是agentctl declare,12 条验证命令里比较有代表性的几条是这样:
# 1. 导出当前 Agent 的完整能力声明 agentctl declare export --format json > current.json # 2. 校验声明内引用的工具是否真实存在 agentctl declare verify --strict # 3. 对比两个版本声明的差异,用于排查"这个 Agent 能做什么变了没有" agentctl declare diff --from v1.2.0 --to v1.3.0 # 4. 尝试调用一个声明之外的接口,验证是否被拒绝 agentctl declare probe --tool internal.api.invoke # 5. 查看当前声明的策略模式,是默认拒绝还是默认允许 agentctl declare policy --mode实测的结论让我挺意外:很多问题不是出在 Agent 不遵守声明,而是出在声明本身有漏洞。比如工具列表里声明了一个搜索工具,但声明里没有限制搜索范围,Agent 就能用它去搜内网资源。所以verify --strict非常重要,它会检查每个工具是否带上了 scope 限制。
12 条命令跑下来的结果是:所有"声明之外调用"的探测请求都被拒掉了,声明层的保证是有效的。它给出的核心保证可以总结为:能力边界可审计、权限变更可 diff。你可以随时问一个常驻 Agent:你会做什么、你不会做什么,答案不是模型自己想的,而是声明文件说了算。
2.3 声明文件写得好没用,运行时必须强制
我踩过这个坑。早期声明文件写得很漂亮,但运行时 Agent 真正调用的工具是在代码里动态注册的,声明形同虚设。后来我把 Agent 的工具加载机制改成只从声明配置加载,代码里不允许出现任何额外的add_tool调用。谁想在运行时加工具,就必须先改声明文件再走发布流程。
这一点其实就是 harness 的典型职责:把模型的能力面约束在轨道之内。声明层能保证"你没声明就干不了",但它不能保证"你声明了也不会干错"。一个工具合法但不该在这种场景下调用,声明层管不住,那是校验层和预演层的事。
3. 校验层给什么保证:模型输出和工具结果不再是无底洞
3.1 常驻 Agent 的三个数据信任边界
校验层解决的是"数据可信"问题。在常驻 Agent 里,外部数据源有三个,每一个都不能默认信任:
第一是用户输入。这个大家都会做,防止 prompt 注入、防止用户传超长文本,常规做法都有。
第二是模型输出。这个是最容易被忽略的。你以为模型会严格按 JSON 格式输出,但模型在 token 概率上自由发挥的空间太大了。少一个逗号、多一个字段、枚举值拼错,都会让下游状态机走进死胡同。
第三是工具返回结果。第三方 API 不会因为你是 AI Agent 就保证返回结构稳定。字段多一个、少一个、类型从字符串变成数字、直接返回 null,这些都是常态。
三个信任边界中,工具返回和模型输出是大多数 Agent 项目校验做得最弱的地方。因为用户输入有现成的框架,而模型输出和工具返回的校验你得自己设计 schema。
3.2 16 条命令实测:校验层扛得住什么
我给校验层封装了一套注入测试,命令大概长这样:
# 注入一个缺字段的模型输出,观察下游是否崩溃 agentctl validate inject --phase output --schema ticket.schema --payload missing_field.json # 工具返回 null 时,校验层是否把错误变成结构化异常 agentctl validate inject --phase tool_result --payload null_result.json # 工具返回超大对象时,是否触发最大体积保护 agentctl validate inject --phase tool_result --payload oversize_10mb.json # 模型输出了非法枚举值,是否被显式拦截 agentctl validate inject --phase output --schema priority.schema --payload invalid_enum.json # 某个工具调用超过 5 秒未返回,是否触发超时与取消 agentctl validate inject --phase tool_call --timeout 5s16 条命令跑下来的结论是:校验层能把所有非法数据变成"显式的错误",而不是让错误静默扩散。但这里有个关键认知:显式错误不等于正确处理,校验层的价值是让系统不崩溃、让错误可以被定位、可以走重试或降级逻辑,而不是凭空把错误数据变正确。
3.3 校验层最容易踩的两个坑
坑一:只校验用户输入,不校验模型输出。很多 Agent 项目把大量精力花在用户输入的 prompt 注入防护上,却对模型输出的合法性放水。其实对常驻 Agent 来说,模型输出才是最大的不可控源,因为它每天都在变。我见过一个 Agent 因为模型偶尔把status字段从 "open" 输出成 "OPEN",导致整个工单流程卡死,整整跑了一天才被发现。校验输出的 schema 应该做到和校验用户输入同等严格。
坑二:把校验做成重试风暴。给工具调用做校验和超时是好事,但如果不区分错误的可重试性,就会出大问题。比如工具返回"字段类型错误",这通常是服务端接口变更了,你重试一万次也没用。正确的做法是给错误打上标记:retryable: true或retryable: false,只有可重试的错误才允许触发重试,并且重试次数要封顶。常驻 Agent 是 7x24 小时跑的,一个无限重试的循环会把你下游服务打到熔断。
4. 版本层给什么保证:prompt、技能、配置都能回到过去
4.1 版本层管哪些资产
普通后端服务要版本化的是代码和数据库 schema,常驻 Agent 要版本化的资产就多得多。我现在的目录结构是这样的:
agent-assets/ prompts/ triage.md summarize.md tools/ ticket.query.yaml ticket.reply.yaml skills/ email-drafting.md knowledge-lookup.md configs/ agent.yaml memory-schema.jsonprompt 文件、工具定义、技能包、声明配置、记忆库的 schema、甚至知识库的快照,全部进版本仓库。一行 prompt 的改动就是一次发布,必须可追溯、可回滚、可比较。
4.2 22 条命令实测:回滚真的能落吗
版本层最重要的验证是"坏发布之后能不能真正回到过去"。我做了这样一次测试:先发布一版故意写坏的 prompt——让 Agent 把所有工单都标记为紧急,然后立刻执行回滚命令。
实测过程中我最深的体会是,回滚远没有想象中简单。最开始我以为只要 git 恢复文件就行,结果发现回滚代码并不代表回滚 prompt,因为 Agent 进程还跑在内存里,旧 prompt 早就被加载进去了。这就是版本漂移:代码回滚了,运行态没有变,线上行为还是坏的。
我后来加了版本指纹来解决这个问题。Agent 每次启动时会把当前所有资产版本的 hash 写入运行态:
# 查看当前运行的 Agent 实际加载的所有资产版本 agentctl version fingerprint # 回滚某个资产到指定版本 agentctl rollback --asset prompts --to v1.0.3 # 回滚后强制刷新运行态,并校验指纹是否匹配 agentctl rollback --asset prompts --to v1.0.3 --reload --verify-fingerprint22 条命令跑下来,结论是:版本层真正能保证的是所有资产可追溯、可回滚,且运行态和版本指纹强绑定。没有指纹校验的回滚,等于没回滚。
4.3 回滚不是复制旧文件,是恢复整个运行态
常驻 Agent 比普通服务多了一个麻烦:它是有记忆的。prompt 回滚了,但记忆库里可能还存着坏版本运行期间写入的数据。如果旧版 prompt 不认识新版记忆结构,Agent 一上线就会表现异常。
我的做法是给记忆 schema 也纳入版本管理,回滚 prompt 时同时校验记忆 schema 的兼容性。不兼容就拒绝回滚,宁可停机处理,也不默默降级。这里也牵涉到 Agent 记忆设计的一个原则:记忆结构必须显式版本化,不要让它变成隐式约定,否则你总有一天会在回滚时被它咬一口。
5. 预演层给什么保证:干跑一遍,压掉线上回归事故
5.1 预演层不是测试框架,是切换运行副作用
预演层的核心思想很简单:把 Agent 的副作用层替换成模拟实现,保留推理和工具调用决策,但不产生真实副作用。我给工具执行器加了一个 mode 参数:
agentctl dryrun --mode dryrun --prompt-file prompts/triage.md --input "工单: 用户投诉登录失败" agentctl dryrun --mode shadow --prompt-file prompts/triage.md --input "工单: 用户投诉登录失败"dryrun 模式下,Agent 会完整走一遍工具选择的流程,但工具调用返回的是预置好的模拟结果,不会真的发消息、改数据库、调外部 API。shadow 模式则更进一步,真实请求会同时发给新老版本,新版本的结果只记录不落库,用来观察行为差异。
很多人觉得预演层就是写单元测试,其实不是。单测测的是函数逻辑,预演层测的是 Agent 在完整上下文里的决策行为——它看到这段输入后会选哪个工具、会生成什么结构化动作。这个层面的回归问题是单测发现不了的。
5.2 18 条命令实测:预演能压掉哪些事故
我用 18 条命令做了四组回归测试:7 条正常业务回归、3 条恶意输入、4 条外部服务超时、4 条回滚验证。统计下来,预演层发现了两类之前线上踩过的坑:
一类是提示词漂移。新版 prompt 在所有正常用例上表现良好,但在"用户一次性提两个问题"的边缘用例上,Agent 只处理了第一个问题。这种问题通过代码 review 很难发现,但干跑一遍立刻暴露。
另一类是工具选择的回归。换了工具描述之后,Agent 在某些场景下不再选择最合适的工具,而是退回到了一个泛化工具。这类问题在没有真实副作用的环境里更容易观察,因为你可以放慢速度看它的决策过程。
但预演层也有明确的边界。它抓不住三类问题:真实并发下的资源竞争、下游服务的真实脏数据、以及长时间运行才出现的内存泄漏。干跑通过是必要不充分条件,这个认知很重要,不然你会把预演层当成免死金牌。
5.3 影子模式和回放模式的取舍
预演层有两种落地形态。回放模式成本低,用历史真实数据喂给新版本,观察输出和动作是否合理,适合日常每次发布前跑一遍。影子模式成本高,把线上真实请求同时复制给新版本实例,只在后台观察不落库,适合大版本升级前的灰度验证。
我的建议是先从回放开始,把历史上出过事的那批请求沉淀成 regression set,每次改 prompt、改工具、改配置,都先回放一遍。等 Agent 到了要动外部写操作的阶段,再上影子模式。影子模式最大的好处是能暴露真实流量分布下的行为变化,但要做好状态隔离,不然新版本和旧版本共享同一份记忆,影子就变成干扰了。
6. 四层的分工关系与我的落地顺序
6.1 一条请求经过四层时发生了什么
把这四层放在一条真实的 Agent 请求链路里看,会更清楚它们各自的角色。假设我的客户支持 Agent 收到一条工单消息:
- 声明层先检查:这条消息涉及的客户、项目、工单操作,是否在当前声明允许的范围内。超出范围直接拒绝执行。
- 校验层接着检查:模型解析出的工单意图、目标、参数是否符合 schema。工具返回的工单数据结构是否正确,字段类型、枚举值、体积是否合法。
- 版本层全程记录:这次运行的 Agent 代码版本、prompt 版本、工具定义版本、记忆 schema 版本,全部写进指纹。出问题的时候能精确回答"这次行为是哪一版资产产生的"。
- 预演层在重大动作前介入:如果 Agent 决定执行一个写操作,比如给客户发送一封回复邮件,在真实发送前可以要求先走一遍干跑,确认工具选择、参数构造和预期输出都正常。
这一套走下来,Agent 的自由发挥空间被压缩在了一个可控的范围内:模型可以自由推理,但它的行动边界、数据格式、版本归属、变更验证都被底座锁死了。
6.2 用一张表看四层给的保证与边界
| 层 | 回答的核心问题 | 给的保证 | 最常见失败模式 | 实测验证入口 |
|---|---|---|---|---|
| 声明层 | 它能做什么、不能做什么 | 能力可审计、权限可 diff | 声明文件与运行时不一致 | agentctl declare |
| 校验层 | 数据格式是否正确 | 非法输入输出被显式拦截 | 只校验用户输入,忽略模型输出 | agentctl validate |
| 版本层 | 当前跑的是哪个版本,能否回去 | 所有资产可追溯可回滚 | 版本漂移、回滚不彻底 | agentctl rollback |
| 预演层 | 改动在真实副作用发生前是否安全 | 低成本发现回归 | 以为干跑通过等于线上通过 | agentctl dryrun |
这张表是我做实测时贴在最前面的。每次改 Agent 之前我都会看一眼:这次改动会影响哪一层的保证?声明层要改声明文件,校验层要补 schema,版本层要加资产版本,预演层要加回归用例。搞清楚这个,就不会出现"只改了 prompt 但忘了补校验"这种半吊子发布。
6.3 我的落地顺序:先说一句"我不想再裸奔"
四层全部做完当然理想,但如果你是第一次给常驻 Agent 搭底座,我建议按这个顺序来:
第一步,先做声明层和校验层。声明层解决"它能干什么"的边界问题,校验层解决"它收到的数据可不可信"的问题。这两层加在一起,两周内就能完成,收益却是立竿见影的——你至少不会再因为越权调用和脏数据而半夜爬起来修 bug。
第二步,在第一次"被用户发现线上行为变了"之前,尽快把版本层补上。版本层的价值是在出问题那一刻体现的,不是平时。没有版本层,你连"这版 prompt 到底改了啥"都答不上来。
第三步,当 Agent 要触达外部写操作时,再上预演层。只读查询可以缓一缓,但一旦 Agent 会发邮件、会改库、会调生产接口,预演层就不再是可选项了。
如果你在做多 Agent 架构,这四个层更要先搭好。因为多个 Agent 之间互相调用的复杂度,会让单 Agent 时代的偶发问题变成必然问题。每个子 Agent 都先过一遍同样的底座,再谈协作编排。
6.4 最后给一条实操建议
72 条命令的最后一组是 4 条横切冒烟命令,用来确认四层之间没有互相破坏。很多 Agent 项目死在层与层的假设冲突上:声明层允许的工具,校验层的 schema 里没定义;版本层回滚的资产,预演层的回归集没覆盖。所以我建议你每做完一层改动,都跑一遍完整的 72 条命令,而不是只跑受影响的那个子集。
我现在每次发布前都会跑完整套,大概需要十几分钟。这套命令跑完,我才能放心说一句:这次改动我可以让它上线。分层做下来,我最大的感受是:Agent 依然是那个会偶尔自由发挥的 Agent,但我不再害怕它自由发挥了,因为我能回答四个问题:你允许做什么?你这次做对了吗?你用的是哪一版?你打算实际动手之前先试一遍吗?如果你的 Agent 也到了需要常驻运行、需要对接外部工具的阶段,强烈建议从这四层里挑一层先做起来。这 72 条命令不是为了凑数字,每条背后都对应一种具体的失望——我对旧版 Agent 的失望。