第 20 章 可观测与失败定位

评测能告诉你一条用例红了,却不会自动告诉你错在抽取、状态、判定,还是表达。

第 18 章说过,症状总是出现在最后一层,而病因往往不在那里。要把症状变成结论,得能看见系统内部这一轮究竟发生了什么。


一、红了,然后呢

回归集告诉你第十七条用例挂了。打开一看,系统在用户已经说过“手机号先不留了”之后,又问了一次手机号。

然后呢?

第 18 章给过分型的框架——同一个症状可能来自四种病因。但分型需要证据:得知道抽取有没有把那句话解释成拒绝、归并有没有把它写进字段、闸门有没有放行、策略选了什么动作、渲染有没有越权。这些东西如果没被记下来,分型就退化成猜测,而猜测的结果通常是去改最容易改的那一层。

所以这一章分成三件事:该记什么(追踪)、怎么顺着它定位(回查)、以及在压根记不了的时候怎么观测(探针)。

二、追踪不是日志

先限定一下用词。本章说的“追踪”,特指为解释一次判断而留下的决策追踪,不是泛指分布式系统里的那种 tracing——后者面向性能与调用链,通常可以采样。

第 18 章已经列过写入点。这里要说的是它和日志的区别——因为很多系统“日志很全”,但依然定位不了问题。

表 20-1 日志与追踪的区别

日志 追踪
面向什么 运维与报错 解释一次判断
组织方式 时间序列 因果链
采样 通常可以采样 关键路径必须完整
回答的问题 出了什么错 它为什么这么做

一份合格的追踪,要能回答第 18 章那个问题:

这句话,凭什么说得出口?

顺着它往回走:这句话对应哪个已批准的动作 ← 那个动作基于哪个版本的候选状态 ← 那个状态由哪些事件折叠而来 ← 那些事件引用了哪几条证据 ← 那些证据来自哪一句原话。这条链断在哪里,你的定位能力就止于哪里。

四条设计要点:

第一,用稳定的标识把因果串起来。 会话、轮次、步骤是三级骨架,但真正让链条成立的是互相引用的 ID:

trace_id / turn_id / step_id      骨架
evidence_ids、event_ids           这条状态由哪些观察和解释而来
state_version、base_version       算在哪个版本上
action_id、idempotency_key        这个动作对应哪次执行

关键不是把所有内容写进一条大日志,而是让证据、事件、状态版本、计划动作、执行与观测结果之间能用稳定 ID 互相指认。

第二,记输入也记输出。 只记结果的追踪没法判断“输入是对的、输出是错的”,而第 18 章的错误分型正需要这组对照。

第三,版本必须记。 提示词版本、模型版本与参数、归并规则版本、决策表版本。没有版本的追踪不能回放,而且很多“改了没生效”的乌龙就死在这一条上。

第四,失败与拦截全量记。 这是第 18 章那条:成功路径最容易复原,真正难查的是“它为什么没做”。

三、能回放到什么程度

第 12 章那条折叠语义在这里兑现价值:候选状态是由已提交状态和事件表算出来的纯函数。只要记下了这两样加上归并规则版本,就能离线重算一遍,完全不必调用模型。

但可回放是分层的,不能一概而论:

重放归并      纯确定性,随时可做,最便宜        ← 折叠语义保证
重放控制层    闸门、策略、状态机也是确定性的
重放模型调用  即使固定模型与参数,仍可能不完全一致

在同一份输入快照、同一组规则版本与确定性配置下,确定性组件必须能够精确重放;模型调用即使记下了模型、参数和输入,也只能尽可能近似重放。

后半句里的“配置”不是可有可无的补充。归并器除了事件表之外,往往还要读字段策略、权威规则、开关配置——这些东西的版本对不上,光有事件和规则版本照样复现不出来。

这句话有两个推论。

一是拆分的收益又多了一项:如果按第 16、17 章的目标架构,把能确定执行的职责逐步移出模型,系统里“可精确重放”的部分就会扩大,而状态、判定和权限正是最需要严格复现的部分。

二是证据必须存原文(第 11 章)。回放时需要的是当时那句原话,而不是模型当时对它的解释——如果只存了解释,你重放的是一个已经被加工过的输入,验证不了抽取本身对不对。

不过存原话只是必要条件。模型那一次实际看到的,是系统提示词、本轮输入、工作记忆、当前状态、注入内容和检索结果拼装出来的完整输入。要复现一次模型调用,需要保存或者能够重建这份实际送进去的输入——完整留存、内容散列加版本引用、还是短期保留,可以按成本和合规来选,但“只存了用户原话就以为能复现”是不成立的。

四、沿责任链回查:从症状往输入走

有了追踪,定位就变成一个机械动作。按第 18 章那条判据——找第一个“输入是对的、输出是错的”位置——沿责任链逐段问一个可判定的问题:

表 20-2 沿责任链回查的检查点

检查点 问什么 看追踪的哪一段
表达与验证 说出来的话和已批准的动作一致吗?验证为什么放行? 渲染草稿、验证结果
内部提交 新的状态版本真的写进去了吗? 提交结果、state_version
外部执行与观测 这次调用真的发生了吗?回执是哪一种? 请求、幂等身份、观测结果三态
计划动作 这个状态下,策略应该选这个动作吗? 计划动作
闸门、状态机、不变量 判定和原因码对吗?转换合法吗?有没有该拦没拦的组合? PASS/BLOCK、blockers、转换记录
候选状态 折叠出来的状态对吗? 候选状态、base_version
归并 事件表是对的,折叠结果就对吗? 事件表 → 候选状态
事件 这次观察被解释成正确的事件了吗? 事件候选
证据 原话记对了吗?该记的记了吗? 证据候选、原文

两点说明,都很重要。

第一,这是一份排障检查点清单,不是一套新的运行时架构。 全书正式的结构只有参考架构那一份;这张表只是把它按“出问题时从哪儿看起”重新排了一遍。

第二,“从后往前”指的是沿这一轮实际走过的因果链回查,不是按表格顺序机械倒序。 第 18 章讲过,对话动作和工具动作走的路不一样:没有外部副作用的那一轮,根本不存在“外部执行与观测”这一段;而有工具动作的那一轮,执行和观测发生在渲染之前,观测结果还会回到归并里重新折叠——回查时也要跟着那条环走。

实际操作有个省时的顺序:先看两头。 证据里有没有那句原话,一眼就能看出来;文本和已批准动作对不对得上,也不需要理解状态。两头都对,再往中间找。

中间那段里,归并有一个别处没有的便利:它是纯函数,可以脱离整个系统单独验证。把事件表拿出来喂给归并器,看折叠结果是不是期望的那个——不需要模型,不需要真实会话,几毫秒就跑完。

开头那个例子走一遍:证据里有“手机号先不留了”这句原话(证据层通过)→ 事件候选里却没有 decline(phone)(事件层失败)。结论是抽取没把这句话解释成拒绝,而不是归并丢了、也不是闸门放行错了。要改的是抽取契约和它的用例,不是去渲染层加一条“不许再问手机号”。

五、探针:内部看不见的时候

第 1 章那次实验用三个哨兵排除了“后半段没被模型看到”这个猜测。那一章讲的是它如何终结了一场没有证据的争论;这一节讲它作为常规手段怎么用。

探针的形式很简单:一条极短的、由生僻口令触发的规则,让系统回显某个内部量。 常用的有三种:

存在性探针   放在某个位置的哨兵,能不能影响这一次输出(第 1 章)
回显探针     某个注入字段的原样值是多少(第 7 章的字段哨兵)
版本探针     当前生效的是哪个版本的提示词

第三种最不起眼,也最省时间——它能在三十秒内排除“我改的东西根本没生效”这个高频误判。

生产观察 另一个业务的提示词把这三类做成了三条固定指令,并且规定只在用户输入精确等于指令原文时生效,“诊断基于指令到来前的状态,不改变业务状态,也不计入业务消息计数”:

查询版本   只输出版本号
查询槽位   输出当前所有字段、计数、阶段与「状态一致性」结论
查询闸门   逆序找到最近一条业务回复,按发送前检查表逐项核对

三条里最值得看的是“查询槽位”的最后一行——状态一致性:[通过/不通过+原因]。它把第 15 章那些不变量变成了一个可以随时查询的自检结果,而且后面跟着一份明确的判负清单:字段有值却仍出现在缺失字段里、摘要已发但历史里找不到摘要、任何正式字段没有用户证据……只要命中其中一条,就不许写“通过”,必须重算。

换句话说,这条探针不只回显状态,它同时在执行第 15 章那套不变量检查

用不用探针,取决于你能不能改应用层:

表 20-3 部署形态与诊断方式

部署形态 该用什么
能改应用层 内部健康检查端点:直接读注入字段、状态快照、版本号。更安全、更完整
改不了应用层(平台托管、第三方壳) 受约束的公开探针,是少数可行的观测手段之一

第 7 章那条边界在这里仍然成立,而且要说得更死:

适用边界 只回显经过批准的、非敏感的诊断字段。用户的联系方式、身份信息不能回显;内部客户等级、隐藏价格、内部 ID、风控字段同样不行。口令要足够生僻并且可以轮换;生产环境里探针应当可开关,最好限时开启。还有一条容易忘的:探针本身要进回归集——一个悄悄失效的探针,比没有探针更糟,因为它会给出虚假的“正常”。安全边界在第 23 章展开。

还有一条限度必须记住,它和第 6 章那句是同一件事:

探针只能证明这个哨兵在这个位置、这次条件下可以影响输出;它证明不了附近的规则被完整读取,更证明不了它们会被正确执行。

压成一句就是:可达,不等于理解,更不等于执行。

第 1 章那三个哨兵全部正常响应,反而说明了这一点:内容进得去,规则照样可能不执行。

六、健康检查:别等用户来报错

把探针常态化,就是健康检查。

它和评测回答的不是同一个问题:

评测回答“这一版好不好”,健康检查回答“现在这一刻还正常吗”。

前者主要服务版本比较与发布决策,后者主要服务运行期的状态监测——但这不是严格的二分:评测也可以在线上做影子运行,健康检查同样可以在部署前先跑一遍。一个典型的健康检查包含几项:当前提示词版本是否符合预期、关键注入字段有没有值、几条关键规则是否可达、一次最小对话能不能端到端走通。

再加一组金丝雀用例:从回归集里挑少量最关键的,定时跑,结果异常就告警。它们不用多——重要的是稳定和及时,不是覆盖面。

金丝雀有一条纪律必须守:它们要跑在隔离账号、沙箱,或者只读与模拟的工具环境里。 为了检查系统是否健康,反而真的建了单、发了消息、创建了一条服务请求,等于让诊断本身制造了新的副作用。

第 1 章那份回归表的说明里,这条纪律是用两句很朴素的话写的:

服务请求可能触发外部写入,请确认测试账号与生产侧隔离;测试联系方式使用专用数据。

一句管副作用,一句管数据。 它们不在任何架构文档里,而是写在测试执行说明的最后一行——通常这类句子的出现,意味着它曾经没被遵守过一次。

七、失败模式速查

积累一张“症状 → 先看哪一层”的表,能显著降低起步成本。下面列出几条常见症状,完整版见附录 B《症状与定位速查》;如果是在设计评审或事故复盘中按工程约束反查,可改用附录 C《失败模式速查表》。两张表都应随事故持续增长:

表 20-4 症状与最快验证动作

症状 先怀疑 最快的验证动作
反复问已经提供过的字段 事件层 / 归并层 看事件表里有没有对应的 set
说了“已提交”,但外部没记录 提交层 看观测结果是 SUCCESS 还是 UNKNOWN
回答的是另一个产品的参数 证据层 / 注入 字段哨兵回显当前对象(第 7 章)
换个说法就失效 事件层 跑语义变体用例(第 19 章)
时对时错 方差 同一用例 k 次重复(第 19 章)
改了提示词却没有变化 版本 版本探针
该拦的没拦住 判定层 看 blockers 与原因码

速查表的价值不在于给出答案,而在于把“从哪儿看起”的成本降到最低。

八、记多少,以及记不到什么

可观测不是记得越多越好。追踪有三样成本:存储、性能,以及隐私——真实追踪里会有手机号、姓名和客户信息,这一条在第 5 章提过,第 23 章展开。

务实的做法是把两样东西分开处理:

控制与因果元数据   失败、拦截、关键路径尽量完整
                  (版本、判定、原因码、状态版本、动作与观测结果)

用户原文与敏感字段  服从数据最小化、脱敏、访问控制与保留期

“失败必须留痕”说的是因果链要完整,不等于原始内容要长期全量保存。 失败的追踪里尤其容易夹带手机号、姓名和单位。其余低价值路径可以采样。

最后是一条边界,比成本更重要:

适用边界 追踪能解释系统内部发生了什么——什么进去了、算出了什么、批准了什么、发出了什么。它解释不了模型“为什么这么想”。你能观测到的始终是输入、输出和中间产物,不是模型的内部推理过程。不要把追踪当成模型的思维记录,也不要把模型自己给出的解释当成因果证据——它同样是一段生成的文本(第 4 章)。

九、能定位了,然后

第五篇前两章形成了一组互补能力:上一章让改动可以被证伪,这一章让失败可以被定位。

到这里,可观测的目标可以说得很具体:失败发生时,要有足够的因果记录,能从输出倒查到动作、状态、事件和证据。确定性链路要能精确重放,模型调用只能尽量近似重放;探针只用于诊断可达性,不承担“证明模型理解了规则”的任务。

但这两章有一个共同的前提:输入是善意的,模型是不变的。

如果有人存心构造输入来绕过你的规则呢?如果哪天要换一个模型、或者供应商把模型升级了呢——那些原本最稳的用例,为什么常常是先崩的那一批?