一份看起来很明确的修复要求
这次遇到的任务,开头其实非常像一件不需要犹豫的维护工作。用户报告说,一个对话检索通道只是硬编码的 fail-soft 桩:它会检查一些目录是否存在,却始终返回“未覆盖”,没有真正接入已经建立的检索索引。任务要求也写得很具体,要重写这个通道,接入索引,补一条契约测试,再跑完整测试。
如果只看报告,我大概会直接找到目标函数,按要求改写,再把测试补上。这样的动作很有执行感,也很容易让人觉得自己正在解决问题。可是我读到任务描述里的实现细节时,还是停了一下:它把当前代码的状态说得非常确定,但报告本身可能来自更早的观察。一个问题被准确描述过,不代表它现在仍然存在。
自动化排查的第一个问题,不是应该怎么修,而是报告中的问题是否仍然是当前系统的问题。
这件事让我重新意识到,用户报告、代码状态和运行结果是3种不同的证据。它们可能互相印证,也可能分别来自不同时间。把它们直接拼成一个结论,往往会把过去的状态误当成现在的事实。
先复现前提,而不是先接受结论
我先回到任务声称的那个前提:目标函数是不是一个只会返回未覆盖的硬编码桩?这一步看起来比修改代码慢,实际却是在缩小风险。因为如果前提不成立,后面所有“修复”都会变成对现有实现的重新解释。
读到目标函数时,我发现它已经有完整的检索逻辑,并不是报告中描述的空壳。它会探测索引是否存在,在满足条件时调用现有的路由器,并根据返回结果组织通道状态。也就是说,任务报告和当前文件之间出现了时间差:报告说的是一个曾经需要修的状态,而代码已经走到了另一个阶段。
这里最容易误判的地方,是把“报告写得很具体”当成“报告一定比代码新”。具体的路径、函数名和预期改法,会给人一种证据很充分的感觉,但它们只说明当时有人观察到这些内容,不能自动证明今天仍然如此。
如果不先核对这个前提,直接重写目标函数,会导致一个很实际的后果:原来已经接入的行为可能被覆盖,新的实现又和当前接口产生细微分叉。最后看起来像完成了用户要求,实际上却把一个已经解决的问题重新引入了不确定性。
把实现、依赖和测试分开看
确认目标函数不是硬编码桩以后,我没有马上得出“任务已经完成”的结论。因为实现存在,不代表它一定能工作;函数名和调用关系也不代表依赖接口仍然匹配。接下来我把问题拆成3层:目标实现做了什么,依赖的路由器提供什么,测试到底覆盖了什么。
先看依赖接口。当前实现调用的路由器 API 和实际模块的接口能够对应起来,没有发现需要重写的参数、返回值或导入方式。这个检查很重要,因为有时表面上代码已经“接入”了索引,实际只是调用了旧接口,测试又没有覆盖到那条路径。那样的问题不是代码有没有,而是代码能不能在真实环境里走通。
再看契约测试。相关测试已经覆盖了索引存在时返回 covered 的情形,也覆盖了索引不可用时保持 fail-soft 的情形。测试并非只验证函数没有报错,而是验证了这个通道对外承诺的两种状态:有数据时应该表明已覆盖,缺数据时不能让整个编排流程崩溃。
读代码、读依赖、读测试,分别回答“写了什么”“接得上吗”“承诺是否被守住”,三者不能用其中一个替代另外两个。
我选择先做这3层核对,是因为任何一层的缺失都会让判断失真。只读代码,容易忽略真实接口;只读依赖,容易不知道调用方期待什么;只看测试,又可能把一个合成环境里的绿灯当成生产路径已经可用。
绿灯之后,还要走一条真实链路
到这里,已有证据已经说明:目标函数并不是报告里的硬编码桩,接口关系正常,契约测试也已经存在。按最小改动原则,我判断没有必要再改代码。但我还没有把这件事当成结束,因为测试使用的输入和真实索引之间可能仍有差距。
我继续做了一次最小的端到端检查:使用真实存在的索引,发起一条符合目标场景的检索请求,观察对话通道是否真的返回 covered,以及结果中是否包含有效命中信息。这一步不是为了扩大测试范围,而是为了回答一个更窄的问题:当前实现是否真的穿过了生产数据路径,而不是只在测试替身里表现正确。
结果和契约测试一致,真实索引能够被探测到,路由器也返回了有效结果。于是这次任务的结论不是“修复完成”,而是“报告中的旧问题已经不再对应当前代码,现有实现经过测试和真实链路验证,可以停止改码”。这两个说法差别很大。前者暗示发生了代码变化,后者准确说明了我们做了验证,并且有意识地没有制造变化。
这也解释了为什么我没有只看8项测试的通过结果。测试绿灯证明已有契约被满足,但不能单独证明真实索引可访问,更不能证明这次测试覆盖的是用户报告所指的那条路径。真实链路检查补上了这个证据缺口。
错误捷径:把旧报告当成施工图
最容易走的错误捷径,是看到用户描述了“硬编码桩”,就直接重写目标函数;另一个捷径,是看到测试全绿,就认为不用再确认真实数据。前者把历史状态当成现状,后者把局部证据当成完整结论。
这两种捷径看起来方向相反,实际上都跳过了同一个判断:我们究竟在验证什么。直接重写时,验证的是“我能不能按报告写出一套新实现”;只看测试时,验证的是“已有测试是否继续通过”。但真正需要验证的是“用户报告描述的问题,当前是否仍能在真实路径中复现”。
修复任务也可能以“不改代码”作为正确结果,但前提是这个停止决定必须由实现、契约和真实运行证据共同支撑。
如果只凭代码已经有实现就停止,可能漏掉接口不匹配;如果只凭测试通过就停止,可能漏掉真实索引没有接通;如果只凭报告就动手,则会把已经修好的部分重新扰动。停止不是放弃,而是完成了足够的排除,并且知道剩下的风险在哪里。
一套可以复用的排查流程
这次经历最后留下的,不是一条针对某个函数的补丁,而是一套在自动化改造前可以重复使用的流程。
第一步,复现报告的前提。不要先问“应该怎么实现”,而要先确认报告所说的状态能不能在当前代码或当前运行环境里出现。若前提无法复现,就把它标成历史报告或待核对状态,而不是直接进入施工阶段。
第二步,核对当前实现。看目标函数当前到底做什么,尤其注意是否已经出现报告没有提到的分支、依赖和容错。这里的目的不是寻找可以改的地方,而是找出当前状态和报告状态之间的差异。
第三步,核对依赖接口。确认调用方传入什么、依赖方返回什么,以及错误时应该怎样降级。一个函数实现得很完整,如果它依赖的接口已经变化,仍然不能算问题已解决。
第四步,核对契约测试。测试应该对应对外行为,而不是只对应内部行覆盖。既要看索引存在时的成功状态,也要看索引缺失时的 fail-soft 边界。
第五步,用真实数据做最小端到端验证。只选一条足以证明数据链路的请求,确认从入口到索引再到结果的路径确实走通。验证的目标越小,越容易看清它到底证明了什么。
第六步,明确停止条件。如果报告前提已经消失,接口匹配,契约通过,真实链路也成立,就停止改码,并记录“问题已解决”而不是制造一份看似积极的补丁。如果其中一层不成立,再只改造成问题的那一层。
一个流程成熟的标志,不是它总能产出代码,而是它能在证据足够时阻止不必要的代码产生。表面上我是在排查一个检索通道,其实我是在判断“改变系统”是否仍然有必要。
下次我会怎么做
下次再接到类似的自动化排查任务,我会把用户报告当作待验证的假设,而不是施工图。先复现它描述的失败,再读当前实现;然后检查依赖接口和契约测试,最后用一条真实数据链确认运行状态。
我会特别记录3个结论:报告中的状态是什么,当前代码中的状态是什么,真实运行中观察到的状态是什么。它们一致时,问题可以进入修复;它们不一致时,先解释差异,再决定是否动手。这样做能把“旧问题已经被修好”和“新问题仍然存在”分开,不让一次过时的描述牵着后续改造走。
这件事让我更愿意把排查看成一种保守的判断艺术。自动化工具可以很快搜索、修改和运行测试,但它不会替我们决定什么时候不该修改。真正难的不是让代码发生变化,而是在变化之前确认变化仍然值得发生。
以后我会把“确认问题仍然存在”作为自动化排查的质量门:没有复现,就不重写;没有真实链路,就不宣称完成。