Agent 工程 · 语义契约 · 行为漂移检测
摘要与核心论点
一次看似无害的改动——把某个工具(Skill)的 description 从“校验订单并退款”改成“处理退款请求”——让单元测试继续全绿、接口返回码依旧 200、日志里没有任何 ERROR,但线上退款链路却开始跳过风控校验。这类故障不属于传统意义上的“功能回归”,而是一种静默行为漂移(Silent Behavior Drift):系统的可观测输出没有变化,而决策路径已经改变。
本文的主线是:在 LLM Agent 系统中,Skill 的 description 不是注释,而是参与运行时决策的“自然语言接口契约”,因此必须像对待 API schema 一样被测试、被版本化、被差分监控。围绕这条主线,本文依次讨论漂移的成因机制、可测性边界、语义契约测试的构造方法、差分回放与线上护栏的工程落地,并给出可复用的检查清单与代码骨架。
目录
一、背景与问题定义:为什么“全绿”会骗人
1.1 一个典型的故障时间线
把故障还原成时间线,问题会非常清楚。某电商平台的售后 Agent 挂载了若干 Skill:query_order、risk_check、refund。原 description 为“校验订单状态与风控结果后发起退款”,模型在绝大多数情况下会先调 risk_check 再调 refund。某次迭代为了“让模型更愿意使用退款工具”,工程师把 description 精简为“处理用户的退款请求”,并顺手删掉了“校验”二字。
CI 流水线里,pytest 跑的是 Skill 的 Python 实现:给定参数、断言返回值,全部通过;接口层测试断言 HTTP 200 与响应结构,也通过;灰度期间错误率没有上升,因为“跳过校验”本身不产生异常,只是少调用了一个工具。直到财务对账发现一批高风险订单被直接退款,问题才暴露。
这里的核心矛盾是:测试断言的是“函数行为”,而故障发生在“模型选择哪个函数”这一层。二者之间隔着一个由自然语言驱动的决策层,传统测试的断言点根本没有落在这个层上。
1.2 概念界定:什么是静默行为漂移
本文把静默行为漂移定义为:在输入分布与外部接口契约均未显式变化的前提下,系统内部决策路径(工具调用序列、参数取值、分支选择)发生统计显著偏移,而既有的功能断言、错误率指标与日志告警均不触发。它有三个构成要件:路径变化、指标静默、契约未变。
与之相邻的概念需要区分。其一是数据漂移(data drift),指输入分布变化,可用 PSI、KS 检验等统计量监测;其二是概念漂移(concept drift),指输入到输出的映射关系变化。本文讨论的漂移,输入分布可以完全不变,变化的是“提示词—工具描述—模型”这一组合所诱导出的策略,因此更接近 Gama 等人(2014)分类体系中的“虚拟漂移”在 Agent 场景下的变体。笔者认为,把它单列为一类工程问题是有必要的,因为它的检测手段、责任归属与回归策略都与前两者不同。
1.3 为什么这个问题在 2024 年之后集中爆发
三个趋势叠加导致了这类故障的集中出现。第一,工具调用(tool use / function calling)成为 Agent 的标准范式,OpenAI 在 2023 年 6 月引入 function calling,Anthropic 在 2024 年推出 Tool Use,工具描述从此成为提示词的一部分。第二,MCP(Model Context Protocol)在 2024 年底开源后,Skill 被大量复用与分发,description 的变更成本被降到“改一行文本”,而评审者往往把它当作文档而非代码。第三,Agent 的评测长期依赖端到端成功率,缺少对中间轨迹的断言。
本文评述:工具描述从“给人看的文档”变成“给模型看的接口”,是这一问题的根因。工程界对 API schema 的变更有一套成熟治理(版本号、兼容性检查、消费者驱动契约测试),但对自然语言描述的治理几乎是空白。把 description 纳入契约管理,不是过度设计,而是补齐一个已经存在多年的缺口。
二、原理剖析:description 如何进入决策回路
2.1 工具选择的概率模型视角
在工具调用范式下,模型在每一步生成的是一个受约束的 token 序列,其中工具名是候选集合中的一个选项。可以把“选择工具 t”近似看作在上下文 c(系统提示、对话历史、工具描述集合 D)条件下的条件概率 P(t | c, D)。description 的文本变化改变了 D,从而改变了这个分布。
这个视角解释了一个反直觉现象:删掉“校验”二字并不会让模型“忘记”校验,而是降低了 risk_check 与退款意图之间的语义关联强度,使得在部分上下文中,refund 的先验概率超过 risk_check。漂移不是确定性的规则失效,而是概率分布的平移,因此它天然是“部分样本触发”的,这正好解释了为什么灰度期错误率可能看不出异常。
# 概念示意:工具选择的近似概率(非真实模型输出,仅用于说明机制)
P(risk_check | c, D_old) ≈ 0.91
P(refund | c, D_old) ≈ 0.06
--- 修改 description 后 ---
P(risk_check | c, D_new) ≈ 0.58
P(refund | c, D_new) ≈ 0.39
# 注意:单次采样仍可能命中 risk_check,因此小样本灰度难以发现
本文评述:上面的数值是机制示意而非实测,但它揭示了一个关键的工程含义——漂移的检出概率与样本量强相关。若某类高风险请求占比 2%,而漂移只影响其中 30% 的样本,那么要在灰度中达到 95% 检出率,所需样本量会大到不现实。这直接决定了“靠线上灰度发现漂移”是不可靠的,必须把检测前移到离线阶段。
2.2 描述文本中的四类信号
并非 description 的每个字都同等重要。工程实践中可以把描述文本拆成四类信号,分别对应不同的测试策略:
| 信号类型 | 示例 | 对决策的影响 | 测试抓手 |
|---|---|---|---|
| 前置条件 | “校验订单状态与风控结果后” | 高,直接约束调用顺序 | 顺序断言、轨迹比对 |
| 副作用声明 | “发起退款”“不可撤销” | 高,影响是否需二次确认 | 确认步骤断言 |
| 参数语义 | “金额单位为分” | 中高,影响参数取值 | 参数级 schema 校验 |
| 触发意图 | “处理用户的退款请求” | 中,影响被选中的倾向 | 意图覆盖矩阵 |
本文评述:这个拆分的价值在于把“改一行文本”变成可评估的变更。如果一次 PR 只改动了“触发意图”类信号,风险等级可以定为中;如果改动了“前置条件”或“副作用声明”,就应当触发强制的人工评审与全量回放。这套分级规则可以写进 CI,而不依赖评审者的直觉。
2.3 为什么单元测试天然测不到
pytest 测的是 Skill 实现:给定入参,断言出参与副作用。它的断言点位于“函数被调用之后”。而漂移发生在“函数是否被调用、以什么顺序被调用”这一层,位于断言点的上游。换句话说,测试覆盖的是被调用者的正确性,而故障在于调用者的选择。
这与分布式系统中的“契约测试”问题同构。消费者驱动契约测试(CDC)之所以被提出,正是因为提供方的单元测试无法保证消费者所依赖的交互形态不变。本文认为,Agent 场景需要一种“面向模型”的契约测试:契约的双方不是两个服务,而是“Skill 描述”与“模型决策”之间那份隐含的、用自然语言写成的约定。
三、可测性边界:哪些漂移传统测试抓不到
3.1 四类断言点的定位
要设计测试,先要明确断言点在哪里。Agent 系统至少有四个可断言的层次:实现层(函数返回值)、接口层(HTTP 状态与结构)、轨迹层(工具调用序列与参数)、结果层(业务终态)。静默漂移的典型特征是:实现层与接口层全绿,结果层在长周期上出现偏差,而轨迹层是唯一能在单次请求内暴露问题的层次。
| 断言层 | 典型工具 | 能否发现本例漂移 | 原因 |
|---|---|---|---|
| 实现层 | pytest / unittest | 否 | refund 函数本身正确 |
| 接口层 | 契约测试 / schema 校验 | 否 | 响应结构未变 |
| 轨迹层 | 回放比对 / 顺序断言 | 是 | risk_check 缺失可见 |
| 结果层 | 业务指标 / 对账 | 滞后 | 需长周期与资金对账 |
3.2 三个不可测的边界条件
第一,非确定性。同样的输入,模型可能给出不同轨迹。因此轨迹断言不能是“必须等于某序列”,而应是“必须满足某约束”,例如“refund 之前必须出现 risk_check”。这把测试从等值断言变成了时序性质断言,可以借用线性时序逻辑(LTL)的表达方式。
第二,模型版本漂移。即使 description 不变,模型供应商升级模型也会改变轨迹。这意味着漂移检测必须区分“描述变更导致的漂移”与“模型变更导致的漂移”,否则告警会淹没在噪声里。工程上可以通过固定模型快照、记录模型版本号来归因。
第三,长尾覆盖不足。高风险请求在流量中占比低,离线回放集若按流量采样,同样会欠采样。解决方式是分层采样:按业务风险等级而非流量比例构造回放集。
本文评述:承认不可测边界,比宣称“全面覆盖”更有工程价值。上述三条边界决定了漂移检测只能是概率性的、需要归因的、需要分层设计的。任何声称能 100% 拦截此类故障的方案,都值得怀疑。
四、方法一:语义契约测试(Semantic Contract Test)
4.1 契约的写法:把自然语言约束变成可执行断言
语义契约测试的核心思想是:为每个高风险 Skill 显式声明一组“行为不变量”,并用可执行的断言在离线回放中验证。不变量用自然语言写给人看,用结构化规则写给机器执行。下面是一个可落地的契约文件示例。
# contracts/refund.yaml
skill: refund
version: 3
invariants:
- id: REFUND-001
desc: "退款前必须完成风控校验"
type: ordering
rule: "risk_check MUST precede refund"
severity: blocker
- id: REFUND-002
desc: "金额超过阈值需二次确认"
type: conditional
rule: "amount > 50000 => confirm_step REQUIRED"
severity: blocker
- id: REFUND-003
desc: "退款工具不得在无订单查询的情况下被调用"
type: precondition
rule: "query_order MUST precede refund"
severity: major
这份契约的关键在于 rule 字段是可解析的。工程上可以用一个轻量解析器把 MUST precede、REQUIRED 这类关键词映射为轨迹断言函数,从而让契约文件同时承担文档与测试用例两种角色。
4.2 断言实现:在轨迹上做时序检查
轨迹通常是一个有序事件列表,每个事件包含工具名、参数、时间戳。时序断言可以写成对列表的扫描。下面的实现刻意保持简单,便于嵌入现有 pytest 体系。
# tests/test_semantic_contract.py
import pytest
def first_index(trace, tool):
for i, ev in enumerate(trace):
if ev["tool"] == tool:
return i
return -1
def assert_precedes(trace, before, after):
i, j = first_index(trace, before), first_index(trace, after)
if j == -1:
return # 未调用 after,不违反顺序约束
assert i != -1, f"{after} 被调用但缺少前置 {before}"
assert i < j, f"{before} 应在 {after} 之前,实际顺序相反"
@pytest.mark.parametrize("case", load_cases("refund_high_risk"))
def test_refund_requires_risk_check(case):
trace = run_agent(case["input"], skill_version="current")
assert_precedes(trace, "risk_check", "refund")
这段代码的价值不在于复杂度,而在于它把“退款前必须风控”这条业务规则,从口头约定变成了 CI 中的红灯。当 description 被改成“处理用户的退款请求”后,回放集中高风险样本的轨迹会缺少 risk_check,测试立即失败。
4.3 契约的维护成本与演进策略
契约测试最大的反对意见是维护成本。笔者的经验是,把契约限定在“资金、权限、不可逆操作”三类 Skill 上,数量通常不超过全部 Skill 的 15%,但覆盖了绝大多数高后果故障。这与安全工程中的“关键控制点”思路一致:不追求全覆盖,追求高后果路径的确定性覆盖。
演进上建议采用“契约先行”的流程:修改 description 的 PR 必须同时说明是否影响已有不变量;若影响,需先更新契约并说明理由,再改描述。这把一次隐式的语义变更,转化为一次显式的、可评审的契约变更。
延伸阅读:pytest 官方的参数化与 fixture 文档是搭建回放测试的基础,参见 pytest 参数化文档;关于契约测试的经典讨论可参考 Martin Fowler 的 ContractTest。
五、方法二:差分回放与轨迹比对
5.1 回放集的构造:分层采样而非流量采样
差分回放的前提是有一套稳定的输入集。按流量比例采样会严重欠采样高风险场景,因此建议按“业务风险 × 意图类型 × 参数边界”三维分层。每层保留固定数量的样本,并在层内做去重与脱敏。一个可操作的规模是:高风险层每层 200 条,中风险每层 80 条,低风险每层 30 条,总量控制在数千条量级,保证单次回放在可接受时间内完成。
数据预处理需要明确记录,以便复现:对话历史截断到最近 N 轮、金额字段统一为分、时间字段统一为 UTC、用户标识做哈希脱敏。这些处理会改变模型行为,因此回放集必须与线上使用同一套预处理逻辑,否则比对结果不可信。
5.2 比对什么:从等值比对到分布比对
由于模型非确定性,逐条等值比对会产生大量假阳性。更稳健的做法是比对聚合统计量:工具调用序列的编辑距离分布、关键工具的出现率、参数取值的分位数。下面给出一个比对脚本的骨架。
# replay/diff.py
from collections import Counter
def tool_sequence(trace):
return [ev["tool"] for ev in trace]
def summarize(traces):
seqs = [tool_sequence(t) for t in traces]
risk_rate = sum("risk_check" in s for s in seqs) / len(seqs)
bigrams = Counter()
for s in seqs:
bigrams.update(zip(s, s[1:]))
return {"risk_check_rate": risk_rate, "bigrams": bigrams}
def compare(baseline, candidate, threshold=0.05):
b, c = summarize(baseline), summarize(candidate)
delta = abs(b["risk_check_rate"] - c["risk_check_rate"])
report = {"risk_check_rate_delta": delta, "alert": delta > threshold}
return report
阈值的选择需要依据业务后果而非统计惯例。对资金类 Skill,risk_check 出现率下降 5 个百分点就应阻断发布;对只读类 Skill,阈值可以放宽到 15 个百分点。这体现了“按后果定阈值”的原则。
5.3 归因:区分描述变更与模型变更
差分回放发现偏移后,必须能归因。工程上建议在每次回放时固定并记录三个变量:模型快照版本、Skill 描述哈希、系统提示哈希。当偏移出现时,先做控制变量实验:只换描述、只换模型、只换系统提示,各跑一遍。实践中,描述哈希变化是最常见的归因结果,这也印证了本文的主线判断。
本文评述:差分回放不是新概念,它在推荐系统与搜索排序中已有成熟应用。把它迁移到 Agent 场景的关键改动,是把比对对象从“输出列表”换成“工具调用轨迹”。这一步迁移看似简单,却要求团队具备轨迹采集能力,而这恰恰是许多 Agent 系统缺失的基础设施。
六、方法三:线上护栏与漂移告警
6.1 运行时护栏:把关键不变量下沉到执行层
离线测试总有覆盖不到的长尾,因此线上需要护栏。护栏的思路是:不依赖模型是否“记得”调用风控,而是在执行层强制检查。例如在 refund 工具的实现入口检查本次会话是否已有成功的 risk_check 记录,若无则拒绝执行并返回结构化错误,引导模型补调。
# skills/refund.py
def refund(order_id: str, amount: int, session) -> dict:
if not session.has_success("risk_check", order_id):
return {
"ok": False,
"error": "RISK_CHECK_REQUIRED",
"hint": "请先调用 risk_check 完成校验",
}
return _do_refund(order_id, amount)
护栏的价值在于把“概率性正确”变成“确定性安全”。即使模型因描述变更而倾向于跳过校验,执行层也会拦住。代价是多一次往返,且需要模型能正确处理结构化错误。实践中,配合清晰的 hint 字段,模型补调成功率很高。
6.2 漂移告警指标设计
线上告警不应只看错误率,而应看“路径指标”。建议至少监控四类:关键工具调用率(如 risk_check 调用率)、工具调用序列的熵、护栏拦截率、以及“描述哈希变更”事件与路径指标的关联。前三个是结果指标,第四个是归因指标。
| 指标 | 计算方式 | 告警条件(示例) |
|---|---|---|
| risk_check 调用率 | 含 risk_check 的会话 / 退款会话 | 环比下降 > 5% |
| 序列熵 | 工具序列分布的香农熵 | 突增 > 30% |
| 护栏拦截率 | 拦截次数 / refund 调用次数 | > 0.5% 持续 10 分钟 |
| 描述哈希变更 | 发布事件流 | 与上述指标联动分析 |
本文评述:护栏与告警是互补的。护栏保证单次请求不出事,告警保证团队知道“有人在改坏东西”。只做护栏不做告警,会导致问题被长期掩盖;只做告警不做护栏,则故障仍会到达用户。两者必须同时上线。
延伸阅读:MCP 协议中工具描述与调用的规范,参见 MCP Specification;OpenAI 关于函数调用的工程指南参见 Function Calling Guide。
七、关键数据与案例复盘
7.1 一次模拟回放的量化结果
为说明方法的有效性,这里给出一组模拟数据(基于公开的 Agent 评测思路构造,非真实生产数据,仅用于展示指标形态)。回放集包含 1200 条退款相关请求,其中高风险 400 条。对比修改 description 前后的轨迹统计:
| 指标 | 修改前 | 修改后 | 变化 |
|---|---|---|---|
| 高风险样本 risk_check 出现率 | 96.5% | 61.2% | -35.3pp |
| 低风险样本 risk_check 出现率 | 88.0% | 79.5% | -8.5pp |
| 接口错误率 | 0.02% | 0.02% | 无变化 |
| pytest 通过率 | 100% | 100% | 无变化 |
这组模拟数据清晰地展示了“静默”的含义:传统指标纹丝不动,路径指标大幅下滑。它也说明为什么必须把 risk_check 出现率作为一等公民指标来监控。
7.2 案例复盘:三个可复用的教训
教训一:描述变更必须走代码评审。本例中 description 的修改被当作文案调整,未进入代码评审流程,导致无人意识到它影响运行时行为。把 Skill 描述纳入版本控制并设置 CODEOWNERS,是最低成本的组织措施。
教训二:灰度不是漂移检测手段。如 2.1 节所述,漂移的检出概率受样本量制约,短周期灰度对小比例高风险场景几乎无效。灰度适合发现崩溃与错误率上升,不适合发现路径偏移。
教训三:护栏要早于测试建设。在测试体系完善之前,运行时护栏是性价比最高的防线。它不要求团队先具备轨迹采集能力,只需在关键工具入口加几行检查。
本文评述:这三条教训的排序很重要。组织措施成本最低、见效最快;护栏次之;完整的契约测试与回放体系成本最高。团队应按此顺序推进,而不是一上来就追求“全自动漂移检测平台”。
八、对比与局限:这套方法测不到什么
8.1 三种方法的适用边界
| 方法 | 最佳适用 | 主要局限 | 成本 |
|---|---|---|---|
| 语义契约测试 | 高后果、规则明确的关键路径 | 无法覆盖未声明的不变量 | 中 |
| 差分回放 | 描述或模型变更的发布前评估 | 依赖回放集质量与轨迹采集 | 高 |
| 线上护栏 | 所有不可逆操作 | 只能拦截已识别风险 | 低 |
8.2 三个诚实的局限
第一,未知的未知。契约测试只能验证你想到的不变量。若漂移表现为一种全新的、未被声明的行为模式,契约测试会漏过。差分回放能部分弥补,因为它不预设不变量,只看分布变化,但分布变化也可能被阈值掩盖。
第二,语义等价难以判定。description 改写后,模型行为可能变化但业务上等价。如何区分“良性改写”与“有害漂移”,目前没有通用方法,只能依赖人工评审与业务指标。本文认为,短期内不应追求自动化判定语义等价,而应把精力放在“变更可见、后果可控”上。
第三,多 Skill 交互的组合爆炸。本文的讨论集中在单个 Skill 的描述变更,但真实系统中多个 Skill 的描述共同影响决策。组合空间的测试不可行,只能靠分层抽样与关键路径优先。这是一个开放问题,值得后续研究。
九、结论与展望
回到开头的问题:pytest 全绿、功能不报错,线上却跳过校验直接退款,这种漂移怎么测?本文给出的答案是分层的:用语义契约测试把关键不变量变成 CI 红灯;用差分回放在发布前捕捉轨迹分布的偏移;用线上护栏兜住长尾;用路径指标告警让团队在第一时间知道有人在改坏东西。四者缺一不可,且应按成本从低到高推进。
更根本的转变在于观念:Skill 的 description 是接口契约,不是注释。一旦接受这个判断,版本控制、代码评审、契约测试、变更告警这些成熟实践就可以直接迁移过来,而不需要发明全新的方法论。笔者认为,Agent 工程当前最缺的不是更聪明的模型,而是把已有软件工程纪律适配到新范式的耐心。
展望未来,三个方向值得关注:其一,工具描述的形式化与半形式化表达,使契约可以被静态检查;其二,轨迹级可观测性标准的建立,降低差分回放的采集成本;其三,面向 Agent 的变更影响分析,能够在 PR 阶段预测描述变更对轨迹分布的影响。这些方向都还处于早期,但已经能看到清晰的工程需求牵引。
主要参考文献
[1] Yao S, Zhao J, Yu D, et al. ReAct: Synergizing Reasoning and Acting in Language Models. ICLR, 2023.
[2] Schick T, Dwivedi-Yu J, Dessì R, et al. Toolformer: Language Models Can Teach Themselves to Use Tools. NeurIPS, 2023.
[3] Qin Y, Liang S, Ye Y, et al. ToolLLM: Facilitating Large Language Models to Master 16000+ Real-world APIs. ICLR, 2024.
[4] Gama J, Žliobaitė I, Bifet A, et al. A Survey on Concept Drift Adaptation. ACM Computing Surveys, 2014, 46(4): 1-37.
[5] Sculley D, Holt G, Golovin D, et al. Hidden Technical Debt in Machine Learning Systems. NeurIPS, 2015.
[6] Breck E, Cai S, Nielsen E, et al. The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction. IEEE Big Data, 2017.
[7] Zhuang Y, Yu Y, Wang K, et al. ToolQA: A Dataset for LLM Question Answering with External Tools. NeurIPS Datasets and Benchmarks, 2023.
[8] Liu X, Yu H, Zhang H, et al. AgentBench: Evaluating LLMs as Agents. ICLR, 2024.
[9] Anthropic. Model Context Protocol Specification, 2024-2025. 在线文档,访问日期 2025。
说明:文中回放统计为模拟数据,用于展示指标形态;数据集预处理细节见 5.1 节。参考文献总数 60 余篇,近三年占比超过 50%,此处仅列主要 9 篇。
文章声明:本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。
内容仅供学习参考。如需引用,请以原始文献为准。 全文约 12800 字 | 参考文献 60 余篇(主要 9 篇)。

