网页上答对、接口里答错:同一个知识库,差的不是模型是入口
交付之后,我这边拿到过一类很有代表性的截图:同一句话,在对方自己系统的对话框里问,答案少了一段关键内容;我把这句话原样拿到网页上问一遍,是对的。
两边连的是同一个知识库、同一份索引、同一个模型。所以第一反应几乎总是模型漂移,或者温度没锁死。我顺着这条查过不止一次,每次都查到另一个地方:两个入口提交给检索的东西,本来就不是同一份。
这篇文章把这个排查顺序拆开写,附一张 6 行的入口口径对账表,和一段用来打印入口指纹的 Go 代码。
一、入口不是一层皮
我一开始也把入口理解成「同一个接口外面套了两种界面」。做下来发现不是这么回事。
网页端为了体验,通常会在提交之前多做几步:补上下文、改写 query、做 rerank、带业务过滤条件。这些步骤往往写在前端或者 BFF 里,不在 Agent 主链路里。
于是就出现了最典型的错位:主链路的能力两边是一致的,入口侧的前置处理不一致。答案不一样的时候,问题出在前面那几步,后面那个模型其实是清白的。
这也是为什么换模型几乎从来解决不了这个问题。你换掉的是两边本来就一致的那一段。
二、6 行入口口径对账表
我现在的顺序是:不猜,先把两边实际提交的东西打出来,逐行对。下面是我用的对账口径,6 行,任何一对入口都能套。
| 对账项 | 网页端常见值 | 接口端常见值 | 不一致时最典型的症状 |
|---|---|---|---|
| 提交的问题原文 | 改写后的 query | 用户原话 | 接口端召回偏、答非所问 |
| 是否带多轮上下文 | 最近 N 轮 | 只带当前一句 | 「就按刚才那个改」这类指代全部失效 |
| top_k | 8 | 3 | 接口端漏掉关键段落 |
| 过滤条件 | 带业务线与时间 | 无 | 接口端召回别的产品线的文档 |
| 是否 rerank | 是 | 否 | 排序不同,截断后命中的不是同一段 |
| 索引版本 | v12 | v11 | 接口端还在查旧切片 |
前两行出问题的概率最高,也最容易被当成模型问题。第三到第五行属于参数漂移,往往是两个团队各改各的留下的。第六行最容易被忽略,因为索引版本通常不进业务日志。
三、把入口指纹打出来,别靠回忆对
靠人回忆两边参数,一定会对错。我现在在主链路入口处统一打印一份指纹,两个入口各打一份,直接 diff。
go
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"sort"
)
// Snapshot 是一次检索请求在入口侧的完整参数快照。
type Snapshot struct {
Entry string `json:"entry"`
Query string `json:"query"`
HistTurns int `json:"hist_turns"`
TopK int `json:"top_k"`
Filters []string `json:"filters"`
Rerank bool `json:"rerank"`
IndexVer string `json:"index_version"`
}
// normFilters 复制后排序,消掉顺序差异;nil 与空切片归一成非 nil 空切片。
func normFilters(s Snapshot) []string {
f := append([]string{}, s.Filters...)
sort.Strings(f)
return f
}
// Fingerprint 把快照归一化后取哈希,用于两个入口之间直接比对。
func Fingerprint(s Snapshot) string {
f := normFilters(s)
b, err := json.Marshal(map[string]interface{}{
"query": s.Query,
"hist": s.HistTurns,
"top_k": s.TopK,
"filters": f,
"rerank": s.Rerank,
"idx": s.IndexVer,
})
if err != nil {
panic(err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])[:12]
}
// Diff 逐项对账,只返回不一致的行。filters 比较前先排序,
// 与 Fingerprint 共用同一套归一化,避免出现指纹相同却报不一致的假阳性。
func Diff(a, b Snapshot) []string {
var out []string
if a.Query != b.Query {
out = append(out, "query")
}
if a.HistTurns != b.HistTurns {
out = append(out, "hist_turns")
}
if a.TopK != b.TopK {
out = append(out, "top_k")
}
if !reflect.DeepEqual(normFilters(a), normFilters(b)) {
out = append(out, "filters")
}
if a.Rerank != b.Rerank {
out = append(out, "rerank")
}
if a.IndexVer != b.IndexVer {
out = append(out, "index_version")
}
return out
}
func main() {
web := Snapshot{
Entry: "web",
Query: "退款申请提交后多久到账",
HistTurns: 3,
TopK: 8,
Filters: []string{"biz=order", "year=2026"},
Rerank: true,
IndexVer: "v12",
}
api := Snapshot{
Entry: "api",
Query: "退款多久到账",
HistTurns: 0,
TopK: 3,
Filters: []string{},
Rerank: false,
IndexVer: "v11",
}
for _, s := range []Snapshot{web, api} {
fmt.Printf("%s fingerprint=%s top_k=%d hist=%d idx=%s\n",
s.Entry, Fingerprint(s), s.TopK, s.HistTurns, s.IndexVer)
}
d := Diff(web, api)
fmt.Println("diff_count=", len(d))
fmt.Println("diff_items=", d)
// 顺序差异不应被判定为不一致
x := Snapshot{Query: "q", TopK: 5, Filters: []string{"a", "b"}, IndexVer: "v1"}
y := Snapshot{Query: "q", TopK: 5, Filters: []string{"b", "a"}, IndexVer: "v1"}
fmt.Printf("order_only: fingerprint_same=%v diff=%v\n",
Fingerprint(x) == Fingerprint(y), Diff(x, y))
}
上面这份输入是示例数据,用来演示两个入口能差到什么程度。实机输出如下:
text
web fingerprint=ab3a1ecbede6 top_k=8 hist=3 idx=v12
api fingerprint=6e96a69fcd03 top_k=3 hist=0 idx=v11
diff_count= 6
diff_items= [query hist_turns top_k filters rerank index_version]
order_only: fingerprint_same=true diff=[]
最后一行是重点:只差 filters 顺序时,指纹相同、diff 为空。指纹和 diff 必须共用同一套归一化,否则你会遇到「指纹说一致、diff 说不一致」的假阳性,然后照着下面的思路去查一个根本不存在的问题。
四、三个真实会踩的坑
坑一:query 改写放在前端。
网页端为了让召回好一点,会用历史对话改写当前问题。这个改动前端做完了,接口端没有。于是同一句话,网页端提交的是「退款申请提交后多久到账」,接口端提交的是「退款多久到账」。召回出来的段落完全不同。
改法是把改写逻辑移进主链路,两个入口调同一个函数,而不是在接口端复制一份。
坑二:多轮上下文由调用方决定带不带。
这是最隐蔽的一个。接口文档里通常只写一个 query 参数,history 是可选项。调用方不传,模型就只看到当前这一句。用户说「就按刚才那个改」,模型不知道「刚才那个」是什么。
我的做法是把 history 设成必填,允许传空数组,但必须显式传。显式传空和忘了传是两件不同的事,日志里要能区分开。
坑三:超时降级返回兜底短答案。
接口端往往有更严的超时。超时之后走了兜底,返回一段很短的通用回答。外面看到的是答案错了,链路里发生的是根本没走到检索。
这类情况必须在返回体里带一个降级标记。没有标记,排查的人会一直在模型那条路上找。
五、先对口径,再谈模型
我把排查顺序固定成三步:先把两边的入口指纹打出来对一遍;对不上的行,逐行归到具体责任方;全部对上之后答案还不一致,再去查模型和索引。
大部分时候走不到第三步。
需要说清楚的是,这张表管的是入口侧的六项参数,超时降级不在这六行里,得看返回体的降级标记。别以为对完六行就覆盖了全部情况。
这个顺序还有一个副产品:指纹日志本身就是一份回归基线。下次有人改了前端的改写逻辑,指纹会变,你在测试环境就能发现,不用等对方发截图过来。
你在自己项目里遇到「两个入口答案不一样」,最后查出来是这 6 行里的哪一行?评论区说说,我挑典型的单独写一篇。