上一篇写完的当晚,我给 AI 助手跑了一次"考试"。
考卷是 20 道金标准问题,覆盖文章事实、个人项目、身份档案、拒答边界四个象限。跑分器逐题调接口、断言回答,最后吐出一行结果:
通过 12/20 | 平均延迟 6768ms
12 分。这个数字比任何主观感受都有冲击力——我一直以为它已经"产品级"了,但考卷说:你有 8 个洞。
这篇文章记录这 8 个洞是怎么一个个填上的。不是功能介绍,是一份带解题过程的错题本。
一、先出考卷:断言维度设计
评测的前提是"答案可判定"。聊天式输出千变万化,不能比对全文,只能断言结构性质。我给每道题定义了六种断言维度:
{ "q": "象棋项目用什么算法?", "expect": { "must_contain": ["Alpha"], "must_cite_any": ["/resume"], "tools_any": ["get_project", "list_projects"] } }
must_contain:回答必须出现的关键词(Alpha-Beta 剪枝)must_reject/allow_reject:是否必须/允许拒答(天气题必须拒,写诗题随缘)no_sources:不应附带来源(拒答时带来源说明检索误召回了)must_cite_any:来源应命中预期 URL 之一tools_any:工具轨迹应出现预期工具——验证的是路由决策,不只看答案
最后一项是关键。答案对不代表过程对:如果模型靠 system prompt 里硬编码的身份信息蒙对了"博主是谁",却没调用档案工具,那换个问题它就会露馅。断言工具轨迹等于把"推理路径"也纳入了考试范围。
配套还有两个工程细节:评测请求带 admin token 走专用通道,旁路 IP 限流和缓存(否则 20 题连跑会被自家风控掐死,缓存命中还会让考试失真);日志里这类请求标记 ip=eval,统计时可剔除。
二、12 → 14:盲区与边界
第一批修复对应两类失败。
知识盲区:「临床试验系统用了什么技术栈」「博主会 Go 吗」全部拒答。排查发现这些信息明明在简历文本里,但简历切块后的向量表征被稀释,语义检索够不着。修法不是调阈值——项目名、技能栈这类有限枚举的信息根本不该依赖概率召回。我把档案拆成结构化 JSON,新增 get_profile() 工具精确查询,与已有的 get_project() 同一设计哲学。
域外边界:「北京天气怎么样」不但没拒答,还煞有介事地附了一篇文章当来源;而「帮我写首诗」模型倒是很想帮忙。这说明拒答规则的定义太窄——只覆盖了"知识域内无答案",没覆盖"请求本身在域外"。Prompt 补上一条:与博客无关的请求(天气、创作、生活问询)一律用固定句式拒答。
14 分。但真正的硬仗在后面。
三、Prompt 劝不动的,让架构来劝
第三轮失败暴露了一个更顽固的行为:未查先拒。「点赞功能怎么实现的」这种题,模型一轮工具都没调,直接回答"暂无相关内容"。我在 Prompt 里写了"严禁未查先拒"——没用,下一轮照样拒。
这就是 Prompt 工程的天花板:它能引导概率,不能保证行为。模型对某些问题的"直觉性放弃"稳定存在,措辞再严厉也只是概率意义上的改善。
解法在架构层。观察到一个可利用的性质:拒答句是固定的九个字——「博客中暂无相关内容」。而我们的回答走流式透传,那么:
第零轮流式输出时,先缓冲一个九字符的窗口不透传。窗口攒满后判断:开头若恰好是拒答句 → 整句拦截(此时还没向客户端推过任何字节),注入一条强制检索指令进入下一轮;否则清空缓冲照常透传——真回答的首字延迟几乎不受影响。
if holdFirst && !emitted && full.Len() < windowLen { full.WriteString(piece) // 窗口内静默缓冲 if strings.Contains(full.String(), rejectPhrase) { return streamRound{rejectHold: true} // 无损拦截 } // 不是拒答 → 冲出窗口,开始正常透传 }
被拦截的问答会追加两条消息继续循环:assistant 的拒答句 + 一句"请立即调用检索工具核实,确认没有才允许拒答"。同时用标志位保证强制检索最多触发一次,防止死循环。前端还会收到一条状态事件——访客看到的是「🔍 二次确认检索中…」,黑盒变成了可见的严谨。
这一轮之后 18 分。这个案例我总结成一句话:Prompt 管意图,架构管行为。当一个行为规则足够重要、重要到不容许概率性违反时,把它从提示词挪进代码里。
四、18 → 19:一个数字破案
剩两道顽固错题,「点赞功能怎么实现的」重试三次都是拒答。这次仲裁机制明明工作了——工具轨迹显示模型被拦截后真的去调了全文搜索,然后……查了个寂寞,依然拒答。
顺着工具的返回往下挖,真相在一个数字上:
MATCH(title, content) AGAINST ('点赞') = 4.69
门控条件: HAVING score >= 6
那篇讲点赞的文章同时讲了分页、JSON-LD、搜索收录四个主题——单主题词频被整篇稀释,4.69 分死在我自己设的噪声门控下。当初为了挡住低相关噪音加的这个阈值,此刻正把真命中挡在门外。每一道防线都会误伤,关键是知道它的弹道。
修法没有动阈值(降门控会放回当年过滤掉的噪声),而是在工具内部做了自愈:全文搜索未命中时,自动降级补一发语义检索,两路结果合并返回。全文通道擅长专有名词精确匹配,语义通道擅长含义泛化——空结果不再意味着"没有",而是"该换条路了"。工具的空结果是信号,不是终点。
19 分。最后一题是纯粹的中文语言问题:用户问「临床试验系统」,知识库里叫「临床试验数据管理系统」——中间插了四个字,子串匹配直接漏配。解法是把匹配从 Contains 升级为 2-gram 重叠率:短查询切成相邻二字片段,统计被长名称覆盖的比例,超过 40% 即命中。"临床/试验/验系/系统"四个片段命中三个,75%——稳稳过线。
20/20。
五、彩蛋:评测连我也抓了三次
这套评测上线过程中还抓到三个 bug,肇事者是我自己:
- 一个补丁脚本链式执行时中途报错,
&&短路导致后半段修改静默丢失——部署后行为不对才发现; - Go 字符串拼接忘了加
+,中文标点直接进了语法层,编译器报了满屏 invalid character; - 同一段按钮逻辑写在两个补丁脚本里各执行了一遍,线上出现了两个一模一样的按钮,其中一个调用的函数从未被声明。
第三次尤其讽刺:那个死按钮安静地挂在生产环境好几天,没有任何报警——因为它"看起来完全正常"。**人眼 review 对这类残留几乎免疫,能抓住它们的只有两种东西:类型检查器和自动化断言。**这也是我对评测集价值最切身的体感:它不光替我看着 AI,还顺带看着我。
六、账本
| 阶段 | 分数 | 修复 |
|---|---|---|
| 基线 | 12 | — |
| 第一轮 | 14 | 档案工具 + 域外拒答边界 |
| 第二轮 | 18 | 拒答仲裁(架构级) |
| 第三轮 | 19 | 双通道自愈 + 门控复盘 |
| 第四轮 | 20 | 中文 2-gram 匹配 |
总投入约两天,其中真正写"新功能"的时间不到三分之一——剩下都在定位问题、校准考卷、以及被自己的工程债绊倒。
这四篇系列至此收尾:《装上一个了解我的 AI》解决有无,《Agentic RAG》解决能力,《Demo 与产品的最后一公里》解决体验,这一篇解决确定性——让"它大概没问题"变成"这 20 道题它每次都对"。
AI 应用的开发范式正在从"调 Prompt 看感觉"转向"改一行,跑一遍考卷"。早一天建立评测,就早一天从玄学里毕业。
系列完整目录: ① 装上一个了解我的 AI · ② 从 RAG 到 Agentic RAG · ③ Demo 与产品之间的最后一公里 · ④ 本篇