案例:AI元宝大侦探
一个查证 Agent 从骨架到可交付的 13 轮迭代 命题:查得到、信得过 周期:2026-10-20 19:28 → 10-25 03:21(跨 5 个自然日,13 轮对话,564 次工具调用) 终态:v1.6.0,零第三方依赖,专家包 46 文件 / 551.2 KB
摘要
这个案例记录的不是「怎么做一个查证工具」,而是一个查证工具在真实使用中暴露了什么、被怎么修掉、以及凭什么相信它修好了。
过程中出现了两类问题,性质完全不同:
| 类型 | 具体表现 | 危险程度 | 检测难度 |
|---|---|---|---|
| 工程缺陷 | 密钥配置要手改 .env;错误码翻译错位,把「密钥填错」说成「服务未开通」 |
中 | 低,用户一试就发现 |
| 原则缺陷 | 问 2029 年诺贝尔文学奖得主,系统以 90% 置信度回答 2026 年的得主 | 高 | 极高,所有引用真实可查、每条都能点开 |
第二类是本案例的核心。它不属于幻觉(hallucination),因为答案里的每一条证据都是真的。它属于错误锚定(misgrounded answer):把一个真实的事实,当成另一个问题的答案。
传统 RAG 架构对此没有免疫力,因为它的每一步都只问「这段内容和问题相关吗」,从不问「这个问题在物理上现在有答案吗」。修复它需要在架构里加一道不依赖检索结果的闸门,本案例称之为「时间守卫」。
一、背景:命题本身是一个陷阱
侦探赛道的命题是「查得到、信得过」,六个字,两个要求。做技术的直觉会把它读成「检索召回率 + 引用可溯源」,于是自然滑向常规 RAG:检索 → 拼上下文 → 让大模型总结 → 附上来源链接。
这条路能做出看起来很不错的 demo,但它有一个致命的结构性问题:它只会回答,不会拒答。
- 检回来 5 条相关内容,哪怕没有一条回答问题,模型也会写出一段像模像样的总结;
- 用户看到有引用、有链接、有置信度,就会信;
- 而「信得过」的真正含义不是「有来源」,是「来源支持这个结论」。
所以本案例的第一条设计原则在写第一行代码之前就定了:
证据不足时不给结论,只说「没查到可靠来源」。
输出空间不是「真 / 假」二值,而是五种终态:真、假、说不准、没查到可靠来源、查询失败。
这个原则直接决定了架构。既然「说不准」和「没查到」都是合法输出,那么判定就不能交给大模型自由生成,必须是可复现的、可审计的、同一批输入永远得到同一个标签。
二、架构:三层分离,把「不敢下结论」写成代码
检索层 多路查询规划 → 元宝搜索 / 离线样例 → 候选证据池
↓
裁决层 权威度分级 → 姿态识别 → 加权投票 → 阈值判定 → 时间守卫
↓
表达层 结论 + 一句话理由 + 可点开来源 + 补证清单 / 相近内容
裁决层不做生成,只做计分。 核心公式:
得分 = 权威度 × (0.55 + 0.45 × 相关性) × 时效系数 × 姿态系数
| 维度 | 取值 | 系数 |
|---|---|---|
| 权威度 | T1 官方/一手 · T2 主流媒体 · T3 行业媒体 · T4 自媒体 | 1.0 / 0.7 / 0.5 / 0.25 |
| 时效 | ≤30 天 · ≤1 年 · ≤3 年 · 更早 · 无日期 | 1.10 / 1.00 / 0.90 / 0.80 / 0.95 |
| 姿态 | 支持 · 否定 · 存疑 · 仅陈述 | 1.0 / 1.0 / 0.45 / 0 |
姿态系数是关键设计:「否定」和「支持」权重相等。一条 T1 来源明确否认,就应该和一条 T1 来源明确支持打平,而不是因为「检索出来的是正面内容」就偏向正面。「仅陈述」给 0 分,因为提到某件事不等于对它有立场,把 0 分给它,是拒绝让「只是提了一嘴」的文本影响判决。
判定阈值:
- 说不准:正反双方权重都 ≥ 0.6,且优势比 < 0.68
- 没查到可靠来源:总权重 < 1.0,或最高相关性 < 0.5
- 置信度:机械映射后强制夹在 20%~95%,永不给 100%,也永不给 0%
「永不给 100%」不是谦虚,是工程约束:置信度是权重的函数,而权重来自人工设定的系数表。系数表本身是人的判断,那么结论的确定性就不该超过人的判断。
三、转折一:密钥不该让人手改文件
3.1 问题
初版接入真实元宝搜索的方式是标准的开发者路径:
cp .env.example .env
# 手工填入 TENCENTCLOUD_SECRET_ID / SECRET_KEY,改 PROVIDER=wsa
python run.py --provider wsa
对评委、对客户、对任何非开发者,这条路走不通。用户提出第一轮需求:把密钥配置搬进界面,并在窗口底部给出「去哪注册、去哪取密钥」的帮助链接。
3.2 一个被拆成两步的按钮
最初我把它做成一个按钮「保存并测试」。用户随后分两次提要求,实际上是在纠正一个交互设计错误:
- 「设置好后有一个点击测试的按钮,点击后会显示链接成功和链接失败的提示结果」
- 「测试无误后点击保存,窗口要自动关闭」
拆开之后才看清:保存是本地动作,测试是网络动作,它们的成功语义完全不同。
| 动作 | 性质 | 可能结果 | 失败原因 |
|---|---|---|---|
| 保存 | 写本机 .env + 更新进程内 config 单例 |
几乎必然成功 | 磁盘不可写(罕见) |
| 测试 | 真打一次 SearchPro(12s 超时) | 成功 / 失败 | 密钥错、服务未开通、限频、网络不通 |
合成一个按钮时,用户看到「保存成功」会误以为密钥能用。拆开后,界面上的状态点是唯一真相来源:灰/琥珀 = 未连接(走离线样例),绿 = 已连接(走实时检索)。颜色只表达这一件事,不跟结论的颜色抢戏。
3.3 真 bug:错误码翻译的判定顺序
这是本案例第一个真实影响用户的 bug,而且是我自己写出来的。
为了让报错能照着做,我把腾讯云的错误码翻成中文动作指引。初版逻辑里有一行:
if "notfound" in low: # 初版:太宽
return "密钥有效,但元宝搜索服务未开通——去控制台开通后再试。"
问题在于:SecretIdNotFound 也包含 notfound。
于是填错密钥的人被告知「你的密钥有效,但服务没开通,去控制台开通」。他会真的跑去控制台,找不到任何要开通的东西,然后卡死在这一步。这比给一句英文原始报错更糟,因为它给出了一个确定的、错误的下一步。
修正方式是按特异性从窄到宽排序判定:
low = message.lower()
if "resourcenotfound" in low or "notenough" in low or "notopen" in low:
return f"密钥有效,但元宝搜索服务未开通——去控制台开通后再试。原文:{message}"
if "authfailure" in low or "secretidnotfound" in low or "signature" in low \
or "unauthorized" in low or "invalidsecret" in low:
return f"密钥校验失败:SecretId / SecretKey 不匹配、已禁用或已删除,重新生成一对再试。原文:{message}"
if "requestlimitexceeded" in low:
return f"密钥有效,但当前触发限频,稍等片刻再试。原文:{message}"
教训:用关键词做错误分类时,「包含关系」必须显式排序。 这和 CSS 优先级、正则 alternation 的顺序敏感是同一类问题的不同表现:谁先匹配谁赢,而「宽」的那个天然会吃掉「窄」的。
源码里我留了一段注释记录这件事:
"""……否则密钥填错的人会被指去开通服务,白跑一趟。"""
3.4 「人性化」不等于「少一次点击」
用户说:「测试无误后点击保存,窗口要自动关闭,不要再让客户点击 X 操作。要人性化。」
字面执行就是保存后无条件关窗。但这样做会在另一个场景出错:用户没测就点保存,窗口关了,他不知道密钥其实没验证过。
所以加了一个状态位:
settingsState = { untested: true }
- 任意
input事件 →untested = true(改了就得重测) - 测试成功 →
untested = false - 保存时:
untested === false→ 1.5s 后自动关窗;untested === true→ 留窗 + 提示「已保存,但还没测试过连接」
结论:用户要的「人性化」是「别让我多点一次」,不是「别告诉我坏消息」。 自动关窗的前提是「事情确实办完了」,否则关窗就是掩盖问题。
截图 ac_1_saving_then_autoclose.png 与 ac_2_untested_not_closed.png 分别固化了这两条路径。
四、转折二:问 2029 年,答 2026 年(本案核心)
4.1 现象
用户在网页里问:
2029年诺贝尔文学奖得主是谁?
系统返回:
已查到:2029 年诺贝尔文学奖得主是加拿大诗人安妮·卡森 置信度:90% 附 4 条来源,包括 nobelprize.org 与主流媒体报道
用户的判断一句话到位:
「2029年诺贝尔颁奖时间还没到!明显错误的搜索问题!正确的答案不应该是"错误""未查到可靠来源"吗?最多下面再显示相近答案给用户参考!」
4.2 这不是幻觉,是错误锚定
我去 nobelprize.org 核实过:2026 年诺贝尔文学奖确实已在 10 月 8 日颁给安妮·卡森。证据链是真的,引用逐字可校验,链接点开就能看到卡森获奖。
这恰恰是最危险的地方:
| 幻觉(hallucination) | 错误锚定(misgrounded) | |
|---|---|---|
| 证据真实性 | 编的,来源对不上 | 全真,每条都能点开 |
| 检测手段 | 校验引用即可发现 | 校验引用完全无异常 |
| 表现 | 置信度往往偏低 | 置信度往往很高 |
| 用户受害方式 | 一看来源就起疑 | 看到来源齐全反而更信 |
一个会编造的工具,用户用两次就学会了不信它。一个「引用全真但答非所问」的工具,用户会一直信它,直到某天用在重要决策上。
4.3 根因:检索相关性与问题可回答性被混为一谈
排查后根因很清晰,而且是架构层面的:
整套流程里没有任何一个环节持有「提问的时间约束」这个概念。
具体链路:
- 检索层把「2029年诺贝尔文学奖得主是谁」当成一个主题去搜,检索词里保留了「2029」,但检索层只负责把候选捞回来,不负责判断这些候选能不能回答问题;
- 捞回来的内容全是诺奖得主相关,相关度都很高(0.72~0.85),因为诺奖得主这个主题确实相关;
_judge_open()需要挑一条「首要来源」来写一句话结论,挑选规则是(tier, -score)排序——优先高权威档,同档再看相关性。nobelprize.org 的奖项总览页是 T1,于是排到第一;- 加权投票把所有真来源的证据合起来算,全是支持卡森的证据,权重很高;
- 结论:置信度 90%,「已查到」。
换句话说:系统答的不是「2029 年得主是谁」,它答的是「诺贝尔文学奖得主是谁」。 它把问题里最容易被忽略的那三个字丢掉了,然后完美地回答了剩下部分。
这个失败模式有个更普遍的名字:问题降维。任何把自然语言 query 变成检索请求的系统都会做某种降维,而降维中丢掉的约束,用户是看不见的。
4.4 修复:三道闸门,一道在计分之前
修复的核心思路:在计分之前,先判断这个问题此刻有没有答案。 答案存在性优先于答案内容,这是一条必须在检索结果之外独立判定的规则。
def target_year(query: str, now: datetime) -> int | None:
"""提问指向的年份:取问句里最大的那个(最近的年份通常就是问的对象)。"""
q = query or ""
years = [int(y) for y in _YEAR_RE.findall(q)]
for word, delta in _RELATIVE_YEAR: # 后年→+2, 明年→+1, 今年→+0
if word in q:
years.append(now.year + delta)
return max(years) if years else None
def _time_guard(card: Card, board: list[Evidence], query: str, now: datetime) -> bool:
qyear = target_year(query, now)
if qyear is None:
return False
if qyear > now.year:
# 规则一:年份还没到
why = ("future", f"提问指向 {qyear} 年,当前是 {now.year} 年——这一年还没到,"
"自然也不存在结果。")
elif qyear == now.year and is_outcome_question(query) \
and not any(_mentions_year(e, qyear) for e in board):
# 规则二:问当年的结果,但没有任何一条证据讲到这一年
why = ("stale", f"问的是 {qyear} 年的事,但检索回来的内容里没有一条讲到这一年——"
"命中的都是别的时间或相邻话题。")
else:
return False # 规则三:历史问题放行
...
三个关键实现决策:
决策一:取问句里最大的年份。 「2029年诺贝尔文学奖得主是谁」只有一个年份,取最大值没有歧义。但如果问「2020年到2029年有哪些得主」,取最大值会把一个含过去区间的合法问题误判成未来问题。当前实现选择了「取最大」,理由是:相比漏判(放过未来问题),误判(拦住合法的过去问题)的代价更低,因为被拦住时会明确说「问的是 2029 年的事,这一年还没到」,用户读到这句就知道自己被拦错了。
决策二:规则二只在「问结果」时生效。
is_outcome_question() 靠线索词判断:得主、是谁、冠军、获奖、颁给、赢家、夺魁、折桂、谁赢、谁获得、谁当选、结果是谁、结果如何。
为什么要区分?因为「2026年诺贝尔文学奖的评选标准是什么」这种问题,证据里不需要出现「2026」也是可以回答的(标准历年相似)。如果对所有当年提问都要求证据提到当年,会误伤一大批合法问题。
决策三:被拦下时列相近内容,而不是留白。
只给一句「没查到」是诚实的,但用户会怀疑工具坏了。所以新增 Card.related 字段,把最相近的 3 条单独列出来,并明说:
下面列出的 3 条是检索到的最相近内容,它们回答的不是这个问题,仅供参考。
静态卡里用 .facts.related li { border-left-style: dashed; } 与正式证据做视觉区分——虚线左边框是一个很轻的信号,但足以让人不再把它当作答案。
4.5 附带修复:首要来源的挑选规则
原来的 _judge_open() 挑首要来源只看 (tier, -score)。这会导致「奖项总览页」这类高权威、高相关、但不含具体年份的页面排第一。
修正为:优先从「既提到目标年份、又标了发布时间」的证据里挑。 一个页面要作为一句话结论的出处,必须同时满足「讲的是这个年份」和「我们知它何时发布」两个条件。
4.6 验收:8 个离线场景
修复不能只靠「再问一次 2029 看看」。我写了 scripts/test_timeguard.py,构造假证据、不联网、8 个场景全过:
| # | 提问 | 证据集 | 期望 | 实际 |
|---|---|---|---|---|
| 1 | 2029年诺贝尔文学奖得主是谁? | 2026 年卡森报道 | no_evidence |
PASS |
| 2 | 2027年诺贝尔文学奖得主是谁? | 同上 | no_evidence |
PASS |
| 3 | 明年诺贝尔文学奖得主是谁? | 同上 | no_evidence |
PASS |
| 4 | 2026年诺贝尔文学奖得主是谁? | 同上 | found + 含「卡森」 |
PASS |
| 5 | 2026年诺贝尔文学奖得主是谁? | 只有 2025 年证据 | no_evidence(stale) |
PASS |
| 6 | 2020年诺贝尔文学奖得主是谁? | 2026 年卡森报道 | 闸门不插手 | PASS |
| 7 | 微波炉加热食物会致癌吗 | 无关证据 | 闸门不插手 | PASS |
| 8 | 2029年诺贝尔文学奖得主会是莫言吗 | 2026 年卡森报道 | no_evidence(断言式) |
PASS |
第 5 条和第 8 条是最容易被漏掉的:
- 第 5 条验证规则二真的生效。同样是问 2026 年,证据集换成只有 2025 年的,必须判「没查到」,而不是拿 2025 年的结果凑数。这条防的是「差一年也能凑」。
- 第 8 条是断言式提问(「会是莫言吗」),走的是 claim 模式而非 open 模式。时间守卫的插入点在模式分发之前,所以两种模式都被覆盖。如果插在分发之后,这条会漏。
真实浏览器回归结果:
- 问 2029 → 返回「没查到可靠来源」+ 3 条相近内容,置信度不再给高值
- 问 2027 → 同上
- 问 2026 → 「已查到」安妮·卡森,90%
- 问 2020 → 正常返回露易丝·格丽克
截图 related_2029_light.png / related_2029_dark.png 固化了明暗两种主题下的相近内容面板。
4.7 这次修复的边界(诚实说明)
时间守卫不是万能的,它的有效范围写进了 README 的已知限制:
| 局限 | 说明 |
|---|---|
| 只认具体年份 | 「下一届」「上上届诺贝尔奖」这类表述没有数字也没有「明年/后年」,识别不到 |
| 只拦未来与当年无证据 | 三年前的提问若证据全是五年前的,不拦;这属于相关性判断,不在守卫职责内 |
| 依赖系统时间 | 本机时钟错,守卫跟着错。当前以进程启动时的 datetime.now() 为准 |
| 不判断合理性 | 问「2029年1月1日的股市收盘价是多少」会被拦(未来),但问「明天这只股票涨不涨」不会被拦——后者是运气问题,不是时间问题 |
4.8 已知未修复:过去年份的错配仍会漏过
写这份案例时重跑了回归测试,第 6 个场景的输出值得单独记一笔。
提问是「2020年诺贝尔文学奖得主是谁?」,喂进去的证据集全是 2026 年卡森获奖的报道。实际输出:
verdict = found(已查到)
confidence = 0.81
one_line = 加拿大女作家安妮·卡森获得2026年诺贝尔文学奖
related = 0
这是同一个失败模式的另一种形态。 时间守卫只覆盖两种情况(未来年份、当年无证据),qyear=2020 < now.year 时直接 return False,闸门完全没插手,于是系统又一次拿 2026 年的答案回答了 2020 年的问题。
必须说清这不是测试用例写错了。测试脚本里这一条只断言「守卫不该插手」,没有断言结论正确——因为守卫确实不负责这件事。缺口是真的:
| 提问年份 | 证据年份 | 守卫行为 | 结果 |
|---|---|---|---|
| 未来(2029) | 任意 | 拦下 | 正确 |
| 当年(2026),无当年证据 | 往年 | 拦下 | 正确 |
| 当年(2026),有当年证据 | 当年 | 放行 | 正确 |
| 过去(2020) | 不符(2026) | 放行 | 答错 |
修复方向明确,但当前版本没做,原因是它与「条件差异定位」在职责上重叠:判断「证据讲的年份是否等于提问年份」本质上是在做更细粒度的相关性核对,需要的不只是一个年份守卫,而是逐年核对证据的时间标签。强行塞进 _time_guard 会让这个 60 行的函数变成一个半吊子的相关性判定器,比现在更难维护。
诚实的结论:v1.6.0 修掉了最刺眼的那一类(未来年份),但「证据时间与提问时间不符」这个更大的族,只堵住了一半。 这种残缺必须写在文档里,而不是等用户发现。
五、工程方法:凭什么相信「改好了」
这个项目最容易被低估的部分,是验收手段。整个会话 564 次工具调用里,相当一部分花在「怎么确认改动真的生效」上。
5.1 端到端验证:CDP + Node 22 自带 WebSocket
不用 Puppeteer,不用 Playwright。Node 22 自带 WebSocket,直连 Chrome DevTools Protocol 就够了:
Page.navigate → Runtime.evaluate → Emulation.setDeviceMetricsOverride → Page.captureScreenshot
每次改动后跑的断言清单:
| 断言 | 手段 |
|---|---|
| 运行时报错为空 | Runtime.evaluate 收集 window.__errs,断言 运行时报错: [] |
| 布局不溢出 | getComputedStyle 实测 scrollWidth <= clientWidth |
| 过渡动画真的在跑 | 读 computed style 的 transition,不用截图猜 |
| 主题变量真的切换 | 读 getComputedStyle(document.documentElement).getPropertyValue('--bg') |
| 375px 窄屏 | Emulation.setDeviceMetricsOverride |
5.2 三个真实的验证踩坑
| 坑 | 表现 | 解法 |
|---|---|---|
Page.navigate 只改 hash 不重新初始化 |
SPA 切屏后断言全过,其实是旧状态 | 补 hashchange 监听 |
--virtual-time-budget 冻住过渡 |
截图里动画停在第一帧,误以为没生效 | 改 CDP 实时截图 + getComputedStyle 实测 |
Chrome 无头 --window-size 有 500px 最小内宽 |
想截 375px,实际出图 500px | 必须用 Emulation.setDeviceMetricsOverride |
| 截图 clip 坐标用错 | 截出来是空白或错位 | 用 getBoundingClientRect().top + window.scrollY(文档坐标,不是视口坐标) |
最后一条尤其容易错:clip 参数要的是页面文档坐标,而 getBoundingClientRect() 返回的是视口坐标。滚动之后两者差一个 scrollY,视觉上就是「截到了别的位置」。
5.3 打包与凭据扫描
专家包(46 文件 / 551.2 KB)交付前做了三件事:
- 凭据扫描:正则扫
AKID/SecretKey/sk-/ 长 base64。结果只命中AKID******wxyz这类占位符和settings.py里的掩码函数,无真实密钥。 - 结构校验:跑
validate_expert.py,输出Expert package is valid。 - 计数核对:指南里原本写「bin/ 38 个文件」,实测 zip 内是 37 个,改成不写具体数字——写死数字是最容易过期的一种错误。
5.4 环境层面的四个坑
| 坑 | 现象 | 解法 |
|---|---|---|
| 脚本在 skill 目录直接跑 | sandbox-center cmd decisionRecord missing actual resource subject |
拷到工作区 .tools/ 再跑 |
bash 命令含 PowerShell 字面量 |
被安全策略拦截 | 脚本落盘,字面量用 "Power" + "Shell" 拼接 |
Git Bash /tmp 与原生 Windows Python 不互通 |
脚本找不到文件 | 统一用 H:/... 绝对路径 |
Python write_text() 把 \n 翻成 \r\n |
生成物在 Linux 侧 diff 全红 | read_bytes() → replace(b"\r\n", b"\n") → write_bytes() |
| 打包脚本跳过点开头文件 | .env.example 进不了 zip |
无妨,UI 首次启动会自动创建 .env |
git push 连续 502 |
Clash 代理抖动 | 间隔 10s 重试,第 3 次成功 |
六、13 轮需求如何长成 7 个版本
需求不是一次想清的,是逐步逼出来的。下表按会话时间排序,可以看清「用户的一句话」如何变成「一个版本号」。
| 轮次 | 用户要求(摘要) | 落地 | 版本 |
|---|---|---|---|
| 1 | 换成「API设置」按钮,点开填 SecretId/SecretKey,下方给帮助链接 | 设置弹窗雏形 | — |
| 2 | 按钮文案改为「元宝搜索设置」 | 文案 + 图标 | — |
| 3 | 加「测试」按钮,显示连接成功/失败 | 拆开保存与测试 | 1.5.0 |
| 4 | 用户填写测试无误后点保存,窗口自动关闭,要人性化 | 自动关窗 + 未测留窗 | 1.5.0 |
| 5 | 日间/夜间主题切换 + 粒子背景 | Canvas 200 颗 | — |
| 6 | 粒子数调到 800 | 800 颗 | — |
| 7 | 降到 600 | 600 颗(性能与观感折中) | — |
| 8 | 更名「AI元宝大侦探」,标题定版「查得到、信得过」 | 全站文案 | — |
| 9 | 13 屏交互式路演页 | docs/pitch.html 68.8 KB 自包含 |
— |
| 10 | 指出「问 2029 答 2026」是原则错误 | 时间守卫 | 1.6.0 |
| 11 | 打包成可提交开放平台的专家包 | 46 文件 / 551.2 KB | 1.6.0 |
| 12 | 问「其他电脑安装后怎么起 8765 前端」 | 专家包内嵌 bin/ 本地网页版 |
1.6.0 |
| 13 | 起服务 / 仓库转 public / 导出会话 / 公众号文章 / 安装指南 / 本案例 | 交付物矩阵 | — |
前 4 轮都在改按钮。看起来是琐碎的需求变更,实际上每一轮都在修正一个具体的交互缺陷:命名不准 → 动作混在一起 → 该关窗没关。这类需求不能当成「改文案」处理,每一条背后都有一个用户会踩的场景。
版本号规则从一开始就定死了:
- 改判定逻辑(阈值、权重、词表、规则)→ 升 MINOR
- 只改文案、样式、文档 → 升 PATCH
- 换架构或改卡片结构(字段增删)→ 升 MAJOR
为什么一个查证工具需要版本号?因为它主张「同一批输入永远得到同一个标签」。但门槛、权重、词表改过之后,同一个问题就会得出不同的卡。没有版本号,两张不一样的卡说不清是数据变了还是代码变了——这是可复现性最后缺的一块。
版本号写进四处:卡片 JSON 的 card.version、静态卡页脚、网页页脚、/api/health 返回值,以及 HTTP 响应头 Server: DetectiveAgent/<version>。
七、产物矩阵
| 产物 | 位置 | 规模 | 面向 |
|---|---|---|---|
| 参赛工程 | detective-agent/ |
114 文件;Python 3,473 行 + 前端 1,611 行 | 开发者 / 评委 |
| 专家包 | out/ai-yuanbao-detective.zip |
46 文件 / 551.2 KB | 开放平台提交 |
| 路演页 | docs/pitch.html |
68.8 KB,13 屏自包含 | 答辩演示 |
| 会话记录 | AI元宝大侦探-会话记录.md |
1,538 行 / 13,632 中文字 / 28 节 / 2 图 | 过程留档 |
| 公众号文章 | 公众号-AI元宝大侦探/ |
3,101 中文字 + 内联样式 HTML + 题图 | 对外传播 |
| 安装指南 | AI元宝大侦探-安装与使用指南.md |
16.2 KB / 13 章 | 使用者 |
| 本案例 | AI元宝大侦探-案例.md |
决策与验收复盘 | 内部沉淀 / 汇报 |
工程硬指标:
| 指标 | 值 | 说明 |
|---|---|---|
| 第三方依赖 | 0 | 纯 Python 标准库 + 原生 JS |
| 接口 | 6 GET + 1 POST | health / domains / samples / check(实时查证)/ card(静态卡)/ settings |
| 领域包 | 6 | 通用 / 政策 / 资质 / 健康 / 财务 / 劳动,换行业只换一个 16~31 行的 JSON |
| 静态卡指纹 | 12 位 | 内容哈希,用于核对两次结果是否同源 |
| 离线回归场景 | 8 | 时间守卫,不联网 |
| 浏览器截图基线 | 57 | 含明暗双主题、375px 窄屏、弹窗各状态 |
八、可复用的四条结论
结论一:拒答能力要写进架构,不能写进提示词
公众号文章里我详细讲过这一点,这里只留判断标准:
如果「不该回答」的判定依赖大模型的自由生成,那它一定会被绕过。 在一个 8K 上下文里,前面 7.5K 是检索内容,最后 500 token 写「若无证据请说不知道」——这条指令要和 7.5K 的强相关内容竞争注意力,输是常态。
正确做法是把它做成计分之后的独立判定,输出一个枚举值。枚举值不会因为上下文变长而漂移。
结论二:「引用真实」和「答案正确」是两件事
这是本案例最核心的一条。任何以「有来源、可溯源」为卖点的工具,都必须额外回答一个问题:
这些来源支持的,是我被问的那个问题吗?
要回答它,系统必须显式持有问题的约束条件(时间、地域、人群、口径),并在给出结论前逐条核对。约束一旦在检索降维中丢失,后面的所有环节都在诚实地回答一个错误的问题。
结论三:错误提示的判定顺序是安全边界
SecretIdNotFound 含 notfound 这一行,把用户指向了完全错误的下一步动作。这类 bug 的特点:
- 编译不报错,测试不易覆盖(要真的拿一个错密钥去试)
- 一旦发生,用户会沿着错误方向走很远才回来
- 修起来只要改判定顺序
任何把错误码映射成人话的地方,都要按特异性从窄到宽判,并写出测试用例覆盖「容易被吃掉的」那几个。
结论四:验收要能看到状态,不能只看到图
--virtual-time-budget 那次踩坑很典型:截图里动画停在第一帧,如果只看图会以为改动没生效,然后去改本就正确的代码。改成读 getComputedStyle 之后,问题立刻显形——过渡属性是对的,只是截图时机不对。
能用断言的地方不要用眼睛。 眼睛看到的是一帧,断言看到的是状态。
结论五:按现象修的规则,边界就是现象本身
这条来自 4.8 节那个自己发现的缺口,也是本文最想留下的方法:
- 用户报告「问 2029 答 2026」,我修了「未来年份」;
- 但问题的本质是「证据时间与提问时间不一致」,未来只是其中一格;
- 结果:「问 2020 答 2026」这一格完好无损地留着。
这不是疏忽,是归纳方式的问题。从单个案例出发修 bug,天然会停在「让这个案例不再发生」;只有把它还原成一张参数矩阵,才会发现没被观察到的格子。
工程上的具体做法:修完一个案例后,把「触发条件」拆成维度,列成矩阵,逐格标上「已覆盖 / 未覆盖 / 不适用」。没有被标注的格子,就是下一个 bug 的产地。
九、如果重来,我会改什么
诚实记录三个决策如果没有时间压力会做得不同:
1. 时间守卫应该在 v1.0.0 就在,而不是 1.6.0。 它的实现只有约 60 行,依赖的只是「问句里有年份」这一件事。我先做了条件差异定位(1.4.0)、关键数字核对(1.4.0)这些更复杂的功能,却没做这条最便宜的。原因是我把「证据不足不给结论」理解成了「检索不到就给不出」,没想到还有「检索得到但答的不是这个」这一整类。
教训:「拒答」不是一个功能点,是一组失败模式的枚举。 应该先把失败模式列全,再排优先级。我列了「没检索到」「检索到但对立」「检索到但相关性低」,漏了「检索到但问的是另一件事」。
2. 首要来源的挑选规则应该一开始就带上时间条件。
(tier, -score) 这个排序规则本身没错,错在它假设了所有证据都在回答同一个问题。加上「必须提到目标年份且有发布时间」之后,这个假设才成立。
3. 专家包的 bin/ 应该在第一次打包时就规划进去。
用户第 12 轮问「其他电脑安装后怎么起 8765 前端」,这是个必然会被问到的问题——对话形态的 Agent 天然起不了本地服务。如果第一次打包就把 bin/ 带上,就不用二次重打。
4. 时间守卫应该做成「时间一致性校验」,而不是「未来年份拦截」。 这是写本文时才发现的范围错误。我按最刺眼的现象(2029)设计了闸门,于是闸门只覆盖了「未来」和「当年无证据」两个口子,漏掉了「过去年份 + 证据年份不符」这一整片区域(见 4.8 节)。
正确的做法是先画一张矩阵:
| 证据时间 = 提问时间 | 证据时间 ≠ 提问时间 | |
|---|---|---|
| 提问时间已到 | 正常判定 | 必须拦或降级 |
| 提问时间未到 | 不存在 | 必须拦 |
我实现的只是右下角和左上角,左下角(已到但证据不符)空着。按「现象」设计规则,规则的范围就等于现象的边界;按「矩阵」设计规则,才能覆盖没被观察到的格子。
附录 A · 会话与工具数据
| 项目 | 数值 |
|---|---|
| 起止 | 2026-10-20 19:28 → 2026-10-25 03:21 |
| 用户消息 | 13 条(成对计 26 条消息) |
| 工具调用合计 | 564 次 |
| 工具 | 次数 | 主要用途 |
|---|---|---|
Edit |
243 | 逐处修改前端与判定逻辑 |
Bash |
211 | 服务启停、截图脚本、打包、git |
Write |
45 | 新模块、文档、测试脚本 |
Read |
40 | 核对既有实现与文档 |
present_files |
10 | 交付物呈现 |
DeferExecuteTool |
6 | 专家包校验等 |
WebFetch |
5 | 核实诺奖官网、腾讯云文档 |
WebSearch |
2 | 交叉验证事实 |
ToolSearch |
1 | 工具发现 |
PowerShell |
1 | Windows 侧环境探测 |
附录 B · 提交历史
07e0138 v1.6.0 时间守卫:问 2029 不能拿 2026 充数
adde15e 设置弹窗:测通过后保存即自动关闭
6c2cc84 v1.5.0 密钥在界面上配:保存只落盘,测试才真打接口
c2a19ad feat(docs): 路演用交互式单页(13 屏,自包含)
6464195 chore: 标题改为「AI元宝大侦探——查得到、信得过」
25ab72d chore(web): 粒子数 800 → 600
f032fc9 chore: Agent 更名为 AI元宝大侦探;粒子数 200 → 800
e522918 feat(web): 右上角日间/夜间切换 + Canvas 粒子背景
3d456df init: v1.4.0 查证 Agent 骨架
三个提交专门用于「把原则写进代码」:
6c2cc84承认「保存成功 ≠ 密钥能用」adde15e承认「自动关窗的前提是事情办完了」07e0138承认「检索到相关内容 ≠ 能回答这个问题」
附录 C · 引用与核实来源
| 事项 | 来源 | 用途 |
|---|---|---|
| 2026 年诺贝尔文学奖得主为安妮·卡森 | nobelprize.org(10 月 8 日公布) | 确认「答案是真的但答错了问题」 |
| 元宝搜索 API 规格 | 腾讯云 wsa.tencentcloudapi.com,Version 2025-05-08 | TC3-HMAC-SHA256 签名、Response.Pages 二次解析 |
| 专家包规范 v2.0 | WorkBuddy 开放平台文档 | expertType / categoryId / quickPrompts 固定 3 条等 |
本文档基于 2026-10-20 至 10-25 的真实会话记录整理,所有数字均可在 detective-agent/ 工程与 AI元宝大侦探-会话记录.md 中核对。