网页上答对、接口里答错:同一个知识库,差的不是模型是入口

网页上答对、接口里答错:同一个知识库,差的不是模型是入口

交付之后,我这边拿到过一类很有代表性的截图:同一句话,在对方自己系统的对话框里问,答案少了一段关键内容;我把这句话原样拿到网页上问一遍,是对的。

两边连的是同一个知识库、同一份索引、同一个模型。所以第一反应几乎总是模型漂移,或者温度没锁死。我顺着这条查过不止一次,每次都查到另一个地方:两个入口提交给检索的东西,本来就不是同一份。

这篇文章把这个排查顺序拆开写,附一张 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 行里的哪一行?评论区说说,我挑典型的单独写一篇。

相关推荐
郝琳3 小时前
真实 flaky 18.3%,CI 报 0%,让 Agent 修测试,它删掉了被测的变量
后端·ai编程
kyle~3 小时前
MCP(Model Context Protocol)
人工智能·ai·ai编程·mcp
一级新生3 小时前
Dify 接入大模型:本地还是云端?实战与选型
人工智能·大模型·dify·ollama
潜龙95273 小时前
AI 驱动的移动端自动化开源项目调研报告
人工智能·测试相关
数聚天成DeepSData3 小时前
外部标注需求里该写清哪几项,样例包才验收得动
人工智能·深度学习·机器学习·数据集·deepsdata
2601_962071004 小时前
MiMo-V2.6-Flash 深度解析:相比上一代 V2.5,它到底改了什么
人工智能
AIGC_Dev4 小时前
电商 AI 作图软件哪个好用?2026 主流商品图工具对比推荐
大数据·人工智能·ai工具·电商美工
2601_958352904 小时前
一款模块通吃15类语音产品?AN-93如何实现录音笔、门禁、IPC的快速适配
前端·人工智能·嵌入式硬件·回声消除·降噪处理
亿佰特物联网应用专家4 小时前
物联网无线数据传输模块选型方法论:主流技术全景、四步决策法与多模融合趋势
网络·人工智能·物联网·工业无线模块选型·物联网无线模块选型