上一章结尾说,模型"活着"但回答质量忽高忽低,光靠熔断和兜底管不住,得给生成结果本身加一道质量闸门。这个判断没错,但闸门不能拍脑袋加——你得先知道一个错误回答是从哪一环出来的。检索错了、上下文拼错了、模型自己幻觉了,三者导致的"回答错"看起来一模一样,修法却完全不同。这一章解决定位问题:把一次回答拆成几段,出问题时能指到具体环节。
一次回答的四段链路
回顾一下前面几章搭出来的链路。用户问一个问题,请求依次经过四段:在语义层里查库(第 3 章)、权限过滤(第 4 章)、按开关判断要不要走模型(第 5 章)、模型生成加熔断兜底(第 6 章)。每一段都可能把答案带偏:
- 检索段:语义映射错了,或者向量召回跑偏,命中的文档本来就不对。
- 组装段:检索对了,但拼进 prompt 的时候截断、错位,或者把 A 部门的上下文拼给了 B 部门。
- 生成段:上下文都对,模型还是答错了——幻觉、漏约束、无视上下文。
- 后处理段:模型输出对,但格式化、脱敏、引用回填时弄坏了。
定位的第一原则是:别猜,去回放。每一段都留一份"当时的输入输出",出问题时按段回放,看是哪一段开始错的。
分环节记录
先给链路包一层记录器。下面这个 StageTrace 在每段结束时记下输入输出,不改变链路本身的逻辑:
class StageTrace:
# 记录一次回答在各环节的输入输出,出问题时可逐段回放定位。
def __init__(self, query, dept):
self.query = query
self.dept = dept
self.steps = []
def record(self, stage, input_, output):
# 只保留能定位的最小信息,全文存下来会撑爆存储
self.steps.append({"stage": stage, "input": input_, "output": output})
def answer_question(query, ctx, retriever, model, trace=None):
trace = trace or StageTrace(query, ctx.dept)
hits = retriever.search(query, ctx.dept)
trace.record("retrieve", query, [h.doc_id for h in hits])
prompt = build_prompt(query, hits)
trace.record("assemble", [h.doc_id for h in hits], prompt)
raw = model.generate(prompt)
trace.record("generate", prompt, raw)
final = postprocess(raw, hits)
trace.record("postprocess", raw, final)
return final, trace
这里 record 里存的是"够用就好的最小信息":检索段只记命中的 doc_id 列表而不是整篇文档,生成段记 prompt 和原始输出,后处理段记原始输出和最终结果。这个取舍很关键——如果每段都把全文存下来,一条 trace 就能吃掉几十 KB,量一大根本存不起。定位用不着全文,用得上标识符和差异。
trace 要能落库、能按请求 id 查回来,才能回放。所以生成段之后、返回之前,把 trace 序列化写进一张审计表,键是请求 id。这张表复用第 5 章那套审计,只是多记一列 trace 内容。
用回放定位
有了 trace,定位就变成三道判断题,从前往后筛:
第一道,正确答案在不在检索结果里。把同一 query 丢给 retriever.search 重跑一遍,或者直接看 trace 里记的 doc_id 再查这些文档的内容。如果正确答案根本不在命中文档里,就是检索段的问题,往下查语义映射和向量索引。
第二道,拼进 prompt 的上下文是不是完整正确。看 assemble 段记的 prompt,检查有没有截断、错位、串部门。检索对了但 prompt 错了,是组装段的锅。
第三道,上下文对、答案还错,用同一个 prompt 重放模型。这里有个细节——生成有随机性,一次重放不对不能下结论,得多跑几次看是否稳定:
def locate_fault(trace, retriever, model, ground_truth, n_replay=5):
# 按段排查:检索 → 组装 → 生成,返回最先出错的那一段。
retrieve = trace.steps[0]
if ground_truth not in _doc_texts_of(retrieve["output"]):
return "retrieve"
assemble = trace.steps[1]
if ground_truth not in assemble["output"]:
return "assemble"
generate = trace.steps[2]
wrong = sum(
1 for _ in range(n_replay)
if ground_truth not in model.generate(generate["input"])
)
if wrong >= n_replay * 0.8:
return "generate" # 稳定复现 → 模型/prompt 本身的问题
return "generate_sampling" # 偶发 → 采样温度或随机性
ground_truth 是排查时人工给定的"正确答案关键词"。三道判断题里,前两道不用重跑模型,成本低、结论也确定;只有第三道需要真金白银地重放。所以顺序必须是先检索、再组装、最后生成,把最贵的检查放到最后。如果上来就重放模型,花一堆 token 也分不清是模型错还是喂进去的料就错。
判断标准上我把"稳定复现"的阈值设成 80%:5 次重放有 4 次以上错,归给生成段本身——prompt 有歧义、模型没对齐约束;只有一两次错,归给采样随机性。这个阈值是我自己拍的,没有行业标准,但逻辑上说得通——非确定性错误你修 prompt 没用,得调温度或者加约束。
踩坑
trace 里不能存原文敏感信息。 生成段记了完整 prompt,里面可能带着用户问题、部门上下文、检索到的文档片段。trace 表如果没做脱敏和访问控制,等于把数据从原库又复制了一份到一张更容易漏的表里。落地时 trace 表和正文库的权限要同级,甚至更严。
模型重放的非确定性会误导定位。 同一个 prompt 两次输出可能不一样,拿一次重放结果就下结论,很容易把"采样波动"误判成"生成段坏了"。这也是为什么 locate_fault 里要重放 5 次而不是 1 次。
别把 trace 当成万能监控。 分环节记录是为了"出问题后能查",不是实时告警。它补不了第 6 章的熔断,也不该承担在线判断的开销。定位是事后、离线、按需触发的动作,别把它塞进热路径。
下一章要做的,是把这一章的定位能力接到质量闸门上:闸门不用猜哪段错了,直接拿 trace 里各段的输出去打分,不达标的回答当场拦下、打回重试。