封仁华的工程笔记

三层埋点:如何确认模型真正收到了什么

摘要:在 Agent 系统里排查上下文异常时,把日志打在上层会看到"框架以为发出去的内容",而不是"模型真正收到的内容"。本文说明为什么需要三层埋点、每层能回答什么问题,以及一次因为只看了一层而得出错误结论的经历。

1. 一个看似简单的需求

需求很简单:搞清楚每次请求发给模型的完整内容。

最直接的做法是在业务代码里打印上下文。但这个做法有三个坑,而且都很隐蔽:

坑一:上下文会被二次处理。 业务层组装好上下文之后,框架还会做截断、压缩、消息规范化、工具定义注入。上层看到的是"打算发",不是"实际发"。

坑二:字段位置会变。 有的实现把参数放在请求体顶层,有的塞进 extra_body。如果你只读了一个位置,可能什么都没看到,然后误以为"这个字段是空的"。

坑三:异常会被静默吞掉。 埋点本身出错时,通常写成 try/except: pass 以免影响主流程。于是探针挂了你也看不出来——日志一直是空的,你以为是"没有请求"。

2. 三层的分工

我最终在三个位置同时埋点,因为它们回答的是不同问题:

层次 挂载点 回答的问题 适合排查
L1 插件钩子 请求进入 provider 前 业务层组装出了什么 工具集、人格注入、记忆内容是否如预期
L2 上下文管理器 裁剪 / 压缩前后 这一轮被裁掉多少、剩多少 窗口滑动、截断策略是否生效
L3 出站 payload 序列化完成、发出之前 模型最终收到什么 缓存前缀是否连续、字段是否被改写

选择这三层的原因是一条原则:越靠近边界,越接近事实。

L1 和 L2 都是"系统内部视角",它们反映的是各模块的意图;只有 L3 反映的是结果。当两者不一致时,以 L3 为准。

3. 指纹设计

出站层记录的不是全文(太大),而是每条消息的指纹:

for m in messages:
    content = m.get("content")
    text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False, sort_keys=True)
    record.append([m.get("role"), sha1(text)[:10], len(text)])

三个字段各有用途:

  • role:确认消息顺序没被重排
  • sha1 前 10 位:判断"是不是同一条消息",比全文比对便宜得多
  • 字符数:发现"内容没变但被截短了"这类问题

有了指纹,相邻两次请求可以逐条比对,得出三种状态:

状态 含义 对缓存的影响
纯追加 前缀完整,末尾新增消息 全部命中
尾部替换 除最后一条外全同,末条被改写 全部命中(设计如此)
前缀改写 从第 k 条起不同 从 k 起全部失效

这个判据后来成了判断"改动是否破坏缓存"的回归测试。

4. 一次因为只看一层而犯的错

排查初期,我怀疑工具定义被重复注入导致上下文膨胀。于是写了个探针读取请求里的 tools 字段,结果一直是空。

当时的结论是"这个 provider 没有带工具定义",并据此排除了工具膨胀的可能。

后来在出站层记录完整 payload,才发现工具定义有 45 个、约 9,015 字符——它们根本不在 extra_body 里,而在请求体的另一个字段。我的探针读错了位置,然后我把它当成"不存在"的证据。

这个错误很典型:探针没有返回值,不等于被测量对象为空。它可能只是读错了字段、被异常吞了、或者根本没挂上。

从那以后,探针本身也要被验证:记录一条"我确实执行了"的心跳,以及在显式位置记录读到的字段名和长度,而不是只用真假值判断存在与否。

5. 三条实践建议

第一,埋点要能证明自己活着。 每次写入带时间戳和一条固定标记;没有输出时,能区分"没有请求"和"探针挂了"。

第二,异常不要静默吞。 至少记录一次错误计数。try/except: pass 是埋点失效最常见的原因——探针坏了,你会以为是系统没动静。

第三,把观测点放在最靠近边界的地方。 出站请求是最后一个你还能观测的位置,也是唯一能作为"事实"的位置。

6. 结论

这次排查的技术难度不高,但我花了远超预期的时间,原因不在问题本身,而在我看的位置不对。

观测点放错位置,看到的就都是别人的转述。而一个不会报错的系统,只有你主动去看,它才会说话。