两个入口答案不一致先别调模型:6 行入口口径对账表(附脚本)
企业知识库上线之后,有一类反馈很稳定:用户说系统答错了,发来截图;把那句话原样拿到网页端问一遍,答案是对的。
同一份索引,同一个模型,同一个检索服务。于是排查方向通常直接指向模型,开始调温度、换模型、加 prompt。我在这条路上浪费过时间,现在把顺序反过来:先把两个入口实际提交的东西对一遍,对不上就先修入口。
一、6 行入口口径对账表(接口端视角)
下面这张表是我现在用的对账口径。填表时站在接口端看,症状列写的是线上会露出什么痕迹,方便直接从日志反查。
| 对账项 | 网页端 | 接口端 | 线上露出的痕迹 |
|---|---|---|---|
query 原文 |
经历史对话改写 | 用户原话 | 接口端日志里的 query 短、无主语 |
hist_turns |
带最近若干轮 | 一般为 0 | 多轮会话第二轮起答案明显变浅 |
top_k |
较大 | 偏小 | 引用来源数量少于网页端 |
filters |
带业务线与时间范围 | 常为空 | 召回结果里混进其它产品线文档 |
rerank |
开启 | 常关闭 | 命中段落排序与网页端不一致 |
index_version |
最新切片 | 可能指向旧切片 | 新增文档在接口端查不到 |
这张表的用法是「填一遍」,不是「看一眼」。示例 json 只演示格式,真正排查时必须取运行时打印出来的值,配置文件的默认值和口头描述都不算数。
二、为什么这六行值得单独对
query 与 hist_turns 出问题的概率最高。 网页端为了召回好,常会用历史对话改写当前问题;接口端一般不改写,也不带历史。这两项一缺,接口端提交的就是一句没头没尾的短句。
top_k、filters、rerank 属于参数漂移。 这三处通常是两个团队各改各的,改的人并不知道另一边还有一份配置。
index_version 最容易被忽略。 索引版本号一般不进业务日志,等答案不对想起来查,会发现接口端指向的还是一份旧切片。
三、用脚本对,不用人眼对
人眼对这六行,尤其是在两个环境之间来回切的时候,几乎必然出错。我写了一个很短的对账脚本,把两边的参数快照存成 json,跑一次直接出结果。
python
"""入口口径对账:比较两个入口提交的检索参数快照。
用法:python check_entry.py web.json api.json
退出码:0 = 六个键齐全且值一致;1 = 存在不一致行;2 = 用法错误;3 = 字段缺失或类型不符。
"""
import json
import sys
# 对账顺序即排查顺序,不要调整
ROWS = ["query", "hist_turns", "top_k", "filters", "rerank", "index_version"]
# 这些键必须是指定的类型,否则 True 与 1 会被判成一致
TYPES = {"rerank": bool, "top_k": int, "hist_turns": int, "query": str, "index_version": str}
def norm(v):
"""归一化:filters 排序,其余原样,避免顺序差异被误判为不一致。"""
if isinstance(v, list):
return sorted(v)
return v
def validate(name, snap):
"""返回 (缺失键列表, 类型不符键列表)。字段缺失必须单独报警,
否则两个文件同时少一个键会被判成「一致」。"""
missing = [k for k in ROWS if k not in snap]
badtype = []
for k, want in TYPES.items():
if k in snap and not isinstance(snap[k], want):
badtype.append(k)
return missing, badtype
def show(v):
"""列表用逗号拼接展示,避免长列表把列撑破。"""
if isinstance(v, list):
return ",".join(str(i) for i in v) or "(空)"
return str(v)
def diff(a, b):
out = []
for k in ROWS:
va, vb = norm(a.get(k)), norm(b.get(k))
if va != vb:
out.append((k, va, vb))
return out
def main():
if len(sys.argv) != 3:
print("usage: python check_entry.py web.json api.json")
return 2
with open(sys.argv[1], encoding="utf-8") as f:
web = json.load(f)
with open(sys.argv[2], encoding="utf-8") as f:
api = json.load(f)
hit = False
for name, snap in (("web", web), ("api", api)):
missing, badtype = validate(name, snap)
if missing:
print(f"MISSING {name}: {missing}")
hit = True
if badtype:
print(f"BADTYPE {name}: {badtype}")
hit = True
if hit:
return 3
rows = diff(web, api)
if not rows:
print("OK 六行口径一致")
return 0
print(f"{'对账项':<16}{'网页端':<20}{'接口端'}")
for k, va, vb in rows:
print(f"{k:<16}{show(va):<20}{show(vb)}")
print(f"diff_count={len(rows)}")
return 1
if __name__ == "__main__":
sys.exit(main())
下面是示例数据的实机输出。两份 json 内容如下(仅为演示格式):
json
{"query":"退款申请提交后多久到账","hist_turns":3,"top_k":8,"filters":["biz=order","year=2026"],"rerank":true,"index_version":"v12"}
{"query":"退款多久到账","hist_turns":0,"top_k":3,"filters":[],"rerank":false,"index_version":"v11"}
跑一遍:
text
对账项 网页端 接口端
query 退款申请提交后多久到账 退款多久到账
hist_turns 3 0
top_k 8 3
filters biz=order,year=2026 (空)
rerank True False
index_version v12 v11
diff_count=6
退出码是 1。另外两种退出码也要用上:字段缺失返回 3,用法错误返回 2。字段缺失单独报警这件事很关键,否则有人改了快照结构少打一个字段,两边同时缺这一项,脚本会判成一致,CI 就静默失效了。
四、对完之后的三件事
第一件:把前置处理收进主链路。 query 改写、上下文拼接这些动作放在前端或 BFF 里,就一定会出现两份实现。移进主链路,两个入口调同一个函数,从根上消除分叉。
第二件:可选参数改成显式必填。 history 允许传空数组,但必须传。显式传空和忘了传,在日志里要能区分开,否则排查时无法判断是调用方的决定还是漏传。
第三件:降级必须带标记。 接口端超时走了兜底返回短答案,返回体里要带降级标记。没有这个标记,外面看到的是答案错了,链路里发生的是根本没走到检索,两条路查下去成本差很多。
五、什么时候才该去查模型
六行全部对上,答案还是不一致,这时才轮到模型和索引。
需要说明一句:这六行管的是入口侧参数,超时降级不在其中,要看返回体的降级标记,别以为对完六行就覆盖了全部情况。
这个顺序的意义不只是省时间。参数快照本身就是一份回归基线,下次有人改了前端的改写逻辑,跑一次脚本就能发现,不用等客户发截图。
把表存下来,下次遇到同样的问题,先填一遍再下结论。
你那边最近一次「两个入口答案不一样」,最后查出来是哪一行?