搜索工具中等2026-10-25

【案例集】AI元宝大侦探

一个查证 Agent 从骨架到可交付的 13 轮迭代 命题:查得到、信得过 周期:2026-10-20 19:28 → 10-25 03:21(跨 5 个自然日,13 轮对话,564 次工具调用) 终态:v1.6.0,零第三方依赖,专家包 46 文件 / 551.2 KB

作者 @瓜哥

案例: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 一个被拆成两步的按钮

最初我把它做成一个按钮「保存并测试」。用户随后分两次提要求,实际上是在纠正一个交互设计错误:

  1. 「设置好后有一个点击测试的按钮,点击后会显示链接成功和链接失败的提示结果」
  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 根因:检索相关性与问题可回答性被混为一谈

排查后根因很清晰,而且是架构层面的:

整套流程里没有任何一个环节持有「提问的时间约束」这个概念。

具体链路:

  1. 检索层把「2029年诺贝尔文学奖得主是谁」当成一个主题去搜,检索词里保留了「2029」,但检索层只负责把候选捞回来,不负责判断这些候选能不能回答问题;
  2. 捞回来的内容全是诺奖得主相关,相关度都很高(0.72~0.85),因为诺奖得主这个主题确实相关;
  3. _judge_open() 需要挑一条「首要来源」来写一句话结论,挑选规则是 (tier, -score) 排序——优先高权威档,同档再看相关性。nobelprize.org 的奖项总览页是 T1,于是排到第一;
  4. 加权投票把所有真来源的证据合起来算,全是支持卡森的证据,权重很高;
  5. 结论:置信度 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)交付前做了三件事:

  1. 凭据扫描:正则扫 AKID / SecretKey / sk- / 长 base64。结果只命中 AKID******wxyz 这类占位符和 settings.py 里的掩码函数,无真实密钥。
  2. 结构校验:跑 validate_expert.py,输出 Expert package is valid。
  3. 计数核对:指南里原本写「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 中核对。

LIVE

今日访问
—
VISITS
今日浏览
—
PV
累计访问
—
VISITS
累计浏览
—
PV