召回率从13%涨到50%,Recall@5从13%涨到80%。同一周,我的智能体端到端答完的问题,从10次里2次,掉到10次里1次,再掉到10次里0次。

每一个组件指标都在说我在赢。产品却在死。

打开网易新闻 查看精彩图片

这不是段子,是一个数据分析智能体的真实翻车记录。它跑在银行和经济时间序列的数据湖上,你用大白话提问,它找到对应的序列,建表、派生列、跑变点分析、画图。智能体从头到尾看不到数据本身,它看到的是一个目录,一行一个可度量指标,带名称、单位、频率、覆盖范围。它负责挑,数据库负责算。

这个目录有 55,532 条。这个数字就是整个故事的钥匙。

关键词匹配的荒诞现场

数据迁移期间,语义检索被关掉了。检索退化成关键词匹配,而关键词匹配要求你问题里的每一个词都出现在标签里。你搜"inflation"(通胀),返回的是一条资产负债表调整项——因为消费者价格指数的正式名字叫"Consumer Price Index",里面根本没有"inflation"这个词。

于是有了那组对比:改造前 recall@1 = 13%,recall@5 = 13%;改造后 recall@1 = 50%,recall@5 = 80%。

然后我跑了端到端场景。检索改造前,10次里2次跑完。改造后,10次里1次。我以为是噪声,又优化了一次检索,再跑:10次里0次。

模型到底在干什么

每一个失败的运行长得都一样。搜目录,再搜,一轮里搜12次、17次、23次——却一次都没调用那个真正建表的工具。

最直观的解读是犹豫:面对55,532个选项,它下不了决心。我沿着这个方向想了三套理论,已经开始动手修第二套。

然后我打开了推理轨迹,读了模型对自己说的话。

"第一次搜索返回了8个候选,但输出是空的(只有'8 candidates, best to worst:',后面没有列表)。让我换个词再搜一次。"

"奇怪。搜索结果被截断了。"

它不是拒绝做决定。它是在寻找被从脚下抽走的证据。

那行代码

上下文管理有一条规则:最近三条工具结果原样保留,更早的折叠成第一行。

而一条搜索结果长这样:第一行是表头"8 candidates, best to worst:",下面才是候选列表。这条规则保留了表头,删掉了列表。

模型看到的是"8 candidates, best to worst:",冒号后面空空如也,于是判断这次搜索什么都没返回,再搜一次。

一轮搜到十次时:总提示词8,646字符(约2,161 token),工具结果占窗口30%,候选ID只看得见80个里的18个,78%被删掉了。窗口才用了30%。这条规则为了省下1,800个字符,正在摧毁这一轮的工作记忆。

为什么检索变好反而更糟

检索变好,意味着第一次搜索就能给出像样的候选。像样的候选会诱使模型再搜一次做对比。而第三次之后的每一次搜索,都会再删掉一条结果。

  • 检索差 → 模型早早放弃 → 搜索次数少 → 删除少
  • 检索好 → 模型愿意探索 → 搜索次数多 → 好候选被删光

优化一个组件,通过一个两个组件都不拥有的机制,拖垮了整个系统。Recall@1 在杀死产品的同时,一直在诚实地变好。

同样的错误我还写在了另外两个地方:一个800字符的摘要上限,会在第五个候选处悄悄截断列表;一个按最旧优先填充的候选池,会把最新一次搜索刚找到的东西丢掉。这三处在写下时对当时的目录都是正确的——那时候只有13条。目录涨了四千倍,没有一处被重新审视。

修好它靠的不是更聪明的模型

不是更聪明的模型,不是更好的提示词。三处改动,全都关于"给模型看什么":

  1. 折叠的结果保留内容,而不是保留标签
  2. 每次搜索都重新陈述本轮累积的候选池,最新的排最前
  3. 结果按目录结构分组,而不是返回一个扁平的排序列表

前后对比:跑完三轮的运行从0/10变成9/10;建表的轮次从约7/30变成29/30;每轮发现性调用从12次降到2次。

整个修复过程中,检索质量没有变。Recall 还是50%/80%。分组改变的是呈现,不是排序——而呈现自始至终才是那个卡脖子的约束。

"GDP, Nominal · Türkiye——这个标题下196行中的一行"是一个可判定的选择。同一行放在扁平列表里,就不是。

行业标准解法反手捅了一刀

面对"模型不知道这类问题在这里是怎么被回答的",教科书答案是建一个已验证查询库:把问答对作为范例注入。Snowflake 就是这么做的。我照做了。

只用分组结果时,10次跑完9次。加上已验证查询库后,变成10次跑完3次,再变成5次。它在十次里干掉了六次。

一半的伤害,原因六周前就写在我自己的接口契约里:系统提示词不得包含族列表或序列ID。看过提示词里ID的模型,会自己发明它的变体。而我的范例里,恰恰带着它们解析到的那些族的ID。我读过那个文件。我还是把它发出去了。

另一半更简单:有了分组结果,模型已经能看出匹配聚集在哪里。一个抽象范例反而给了它多余的东西去推理。Snowflake 需要查询库,是因为它的语义模型被限制在32K token,没法展示整个目录。而我可以。同一个问题的两个解法,互相打架。

想告诉一周前的自己三件事

第一,去读模型对自己说的话。关于"犹豫"的三轮理论推演,被一次打开推理轨迹的运行全部终结。模型一直用大白话在描述这个bug。

第二,组件指标可以一路上涨,而系统正在死掉。Recall 在整段窗口里单调上升,产品在同一段窗口里归零。

第三,写下时正确的规则,不会因为数据规模涨了四千倍就自动保持正确。那三处上下文规则,没有一处是错的——它们只是属于一个只有13条目录的世界。