问答系统上线之后,团队经常遇到同一个场景:用户说「你们昨天答错了」,但没人能还原当时到底问了什么、系统召回了什么、为什么给出那个答案。日志里只有一句问题和一句回答,中间过程全丢了。
这篇文章讲日志该记哪三样东西,以及为什么这三样缺一不可。方案不依赖具体模型,换成任何检索增强框架都适用。
一、请求快照:把「当时的世界」固定下来
只记问题是不够的。同一个问题在不同时间问,知识库内容、提示词版本、检索参数都可能不同。事后复现时,环境已经变了。
请求快照要记录四类信息:原始问题、检索参数、提示词版本、知识库快照标识。前三项容易理解,第四项容易被忽略。知识库每天都在更新,没有快照标识就无法回到当时那一版。
function buildSnapshot({ question, topK, minScore, promptVersion, kbRevision }) { return { question, retrieval: { topK, minScore }, promptVersion, kbRevision, createdAt: new Date().toISOString() }; }kbRevision可以是同步任务生成的自增版本号,也可以是索引文件的哈希。关键是它能指向一份可恢复的知识库状态。
二、引用片段:答案的依据要能逐段核对
第二个要记的是召回片段。不是记片段 ID 就够,要记片段的原文与得分。原因很实际:片段可能在下一次同步中被改写或删除,只有原文才能复盘。
记原文也有隐私与体积问题。可行的折中是记前三段完整原文,其余记哈希与位置。多数争议集中在被直接引用的那几段,前三段通常足够定位。
function logCitations(citations, limit = 3) { return citations.map((c, i) => ({ docId: c.docId, chunkId: c.chunkId, score: Number(c.score.toFixed(4)), text: i < limit ? c.text : undefined, hash: contentHash(c.text) })); }分数保留四位小数即可。再高的精度对复盘没有帮助,反而增加存储压力。
三、置信度:把「不确定」也记下来
第三个要记的是模型自报的置信度,或者系统算出的置信分。很多团队不记这个字段,出了问题只能靠人读答案猜。
置信度的来源可以有两类:模型输出的概率或标记,以及检索侧的分数分布。检索侧更容易拿到,也相对稳定。一个简单的算法是把首段得分与平均得分做比:
function confidenceOf(scores) { if (!scores.length) return 0; const sorted = [...scores].sort((a, b) => b - a); const avg = scores.reduce((s, x) => s + x, 0) / scores.length; return Number((sorted[0] / (avg || 1)).toFixed(3)); }比值接近 1 说明各段得分接近,答案可能缺少明确依据;比值明显大于 1 说明有一段显著突出。这个指标不是绝对判据,但能帮团队快速筛出可疑回答。
四、日志表结构与采样策略
| 字段 | 类型 | 说明 |
|---|---|---|
| request_id | string | 请求标识 |
| snapshot | json | 问题、参数、提示词版本、知识库版本 |
| citations | json | 召回片段与得分 |
| confidence | float | 置信分 |
| answer | text | 最终回答 |
| feedback | string | 用户反馈,可空 |
全量记录成本高,可以分层采样:置信度低于阈值的全记,用户点踩的全记,其余按比例采样。这样既控制体积,又保证问题样本完整。
五、复盘时的三个问题
有了这三样,复盘就能回答三个关键问题:当时读到了什么,依据是哪几段,系统自己有没有把握。
如果读到的是旧版本,问题在同步;如果依据片段本身写错了,问题在知识库;如果依据正确但回答跑偏,问题在提示词或模型。三类问题的修法完全不同,没有日志就只能猜。
上线初期不必追求日志的完整性,先把这三样记全。字段可以后补,缺了中间过程的数据补不回来。很多系统不是不会改,而是不知道该改哪里,可回溯日志解决的正是这件事。
(本文由一支长期做企业内容系统的技术团队整理,欢迎同行交流指正。)