首页 核心备考 STAR 案例 评估与护栏 评估深入 护栏深入 核心概念 意图识别 EN

面试准备 · 三个主题,三个深度

记忆、评估、护栏

全部取材于 Mines Financial 项目:一个多智能体检索问答机器人,语料是五家 ASX 矿业公司的年报。每个主题都从架构一路讲到本仓库中的具体代码。

这个页面怎么用

每个问题分两部分。 这样说 是一段简短的答案,按你开口说话的方式写成——四五句短句,用词平白。 如果他们继续追问 是只有在对方追问时才抛出的补充细节。不要照着念这些要点;它们是弹药,不是台词。

解释部分都折叠起来了,好让答案保持易读。任何标成 这样 的术语,点击就会展开一段大白话定义;底部的 术语表 可以一次性把它们全部展开;而每个主题的末尾都有一个折叠的 如果他们反驳 小节。

招聘启事里那三个词,你应该反复听到 「安全、可复现、生产级」。这句话告诉你,这场面试考的不是模型有多聪明,而是你能不能证明一个概率系统行为可控、上线之后能持续证明它、并且在它不对劲时能把它停下来。记忆、评估和护栏,恰好就是这三件事被决定的三个地方。

初级的回答听起来是什么样

「我用了 LangGraph,加了个向量数据库做记忆,写了一些测试用例,在出答案前加了个检查。」——这是在罗列工具,不是在讲决策。

他们想听到的是什么

「我当时是在防哪一类失败,哪一层是最便宜的拦截点,哪个数字证明它奏效了,以及它到现在还覆盖不了什么。」——这是在讲权衡和证据。

1 · 记忆

「记忆系统、上下文管理」——职位描述里直接点名的能力。

Level 1 · 架构

Lead / Senior

在这一层,你要讲的不是一个数据库,而是你当初怎么决定「到底要不要记住」,以及记错了由谁来买单。

1

你会怎么为一个智能体(agent)系统设计记忆?

这样说

我做的第一件事,是拒绝把记忆当成一件东西。「记忆」其实是四个不同的问题被安上了同一个名字,它们的存储方式不同、生命周期不同、失败模式也不同。

第一,我们刚刚说了什么——最近几轮对话。第二,整个会话里发生了什么,这部分必须能扛住服务器重启。第三,关于这个用户或这个实体,我们知道哪些应当比会话活得更久的事实。第四,哪些内容我们此前已经答过,好让我们不必为同一次计算付两次钱。

所以我把每一类分开设计,并且对每一类都写清三件事:存在哪里、活多久、记错了会怎样。最后一条最重要。错误的记忆比没有记忆更糟,因为系统会带着十足的自信把它说出来。

如果他们继续追问

  • 运行状态与持久存储的区别。 状态(state)是单次运行的工作记忆——运行结束它就消失。存储(store)比运行活得更久。把两者混在一起是最常见的设计错误:有人把长期事实放进运行状态然后丢掉了它们,或者把每次运行的临时数据放进存储、从此再没清理过。
  • 每一条记忆都需要来源信息(provenance)。 它从哪来、什么时候来的、来自源文档的哪个版本。没有这些,你既没法让它过期,也没法向审计人员解释它。
  • 记忆投毒才是真正的风险。 如果系统缓存自己的输出,一个坏答案就会变成一个永久的坏答案。所以写入路径必须由质量检查来把关,而不是只凭「我们产出了一个答案」。
  • 面向银行的成本口径。 便宜的记忆省下延迟和 token。错误的记忆则会对客户的钱产出一句自信的假话。这两者并不对称,所以默认行为应该是遗忘。
2

记忆和检索的边界在哪?它们不都是在取文本吗?

这样说

我按内容的产出者来划分。检索读的是别人拥有的事实来源——在我的项目里就是公开发布的年报。记忆读的是我自己的系统产生或推断出来的东西。

这条线之所以重要,是因为信任级别不同。来自年报的一个数字是我可以带着页码引用的事实。来自我自己此前答案的一个数字只是一项主张,而且它当初就可能是错的。

所以检索到的内容可以直接进入答案并附上引用。记住的内容则必须打上标签,而且我得随时愿意把它扔掉、重新检索一次。

如果他们继续追问

  • 实际后果: 当记忆和新鲜检索发生冲突时,新鲜检索赢。记忆是捷径,永远不是权威。
  • 两者用的是同一套机器 ——文本被转成向量、按相似度搜索——这正是人们把它们搞混的原因。技术相同,信任契约不同。
  • 这种混淆是有代价的:团队把自己输出的摘要塞进和源文档同一个索引里,然后系统开始引用自己。
3

上下文窗口是固定的。什么内容能进去,由谁来决定?

这样说

所谓 上下文窗口(context window) ,是模型一次调用中能读取的文本上限——对现代模型来说大约相当于几百页,但它是一道硬天花板,而且你放进去的每一样东西都要花钱、都要加延迟。

最朴素的做法是把一切都发过去:完整对话历史、全部检索到的文档、全部记忆。这会以两种方式失败。它会撞到天花板;而且远在撞到之前,质量就已经下滑了,因为那句关键的话被埋在成千上万句无关内容之中。

所以我把上下文拼装当成一份显式、有序的预算,而不是一次拼接。优先级最高的是当前问题。然后是我确实为它检索到的证据。再然后是较早对话的一段简短摘要。记忆提示放在最后,空间不够时它们最先被丢掉。

关键在于,这个排序是一个我能辩护、也能测试的设计决策——而不是代码碰巧最后拼上去的顺序。

如果他们继续追问

  • 为什么「上下文中段」很危险: 模型对输入的开头和结尾注意得最可靠。埋在大段文本中间的证据,被用上的概率有可测量的下降。
  • 压缩是有损的,而这正是重点。 我用两句话的摘要替换掉十条旧消息。我必须接受某些细节确实丢了,所以摘要提示词必须点名要保留什么——在我这里就是公司、指标、财年、结论。
  • 上下文拼装也是提示注入(prompt injection)风险进入的地方:检索到的文本是不可信输入,却被放在我的指令旁边。参见护栏部分。

Level 2 · 实现

Mid-level

到这一层,问题从「你会怎么设计」变成「它实际是怎么跑的,以及哪里会坏」。机制、阈值、边界情况。

1

你怎么防止对话历史无限增长?

这样说

最简单的办法是滑动窗口:只留最近 N 条消息,其余丢掉。它只要一行代码,但对我们这个场景是错的,因为用户会说「把那个和 Rio 比一下」,而被比较的那个东西已经被丢掉了。

我用的是摘要缓冲区。当历史超过阈值时,我把除最近几条之外的全部内容交给模型,让它压成两三句话,删掉原始消息,再把摘要插回它们的位置。

这样最近几轮保持逐字原样,因为代词和追问指向的正是那里。较早的轮次则以压缩痕迹的形式留存,而不是凭空消失。

一条重要的安全规则:如果摘要调用失败,我什么都不做,保留原始消息。一次失败的压缩绝不能删掉历史。

滑动窗口

保留最近 4 条,丢弃更早的。便宜、确定性强、不需要调模型。代价是会悄无声息地毁掉任何追问的指代对象。

摘要缓冲区

最近 4 条逐字保留,更早的用一段生成的摘要替换。要多花一次模型调用,而且有损,但追问仍然能解析出来。

如果他们继续追问

  • 按 token 触发,而不是按消息条数触发, 前提是消息长度差异很大。我这边按条数触发,因为这里每轮的长度相当均匀——这是一个我说得出口的简化。
  • 摘要会层层叠加。 如果你反复对摘要再做摘要,细节每一轮都在衰减。生产版本会把原始对话记录放在便宜的存储里,每次都从原文重新摘要,而不是从上一份摘要。
  • 删除一条消息必须是给状态层的一条显式指令,而不是重写整个列表,这样并发分支才不会把它复活。
2

解释一下语义缓存。它什么时候帮你,什么时候害你?

这样说

普通缓存匹配的是完全相同的字符串。这对自然语言毫无用处,因为「BHP revenue in FY2024」和「How much did BHP earn in FY2024?」是两个不同的字符串,问的却是同一件事。

而 语义缓存(semantic cache) 通过比较含义来解决这个问题。我把问题转成一个 向量(embedding) ——一串代表其含义的数字——再拿它和历史问题的向量比较。相似度足够高,我就复用旧答案。

在这种领域里它帮助很大,因为少数几个问题就覆盖了大部分流量,而每一次未命中都要付出好几次模型调用外加一次检索的代价。

它害你的地方在「差一点」。「BHP revenue FY2024」和「BHP revenue FY2023」作为句子几乎一模一样,作为问题却完全不同。阈值调得太松的缓存,会自信满满地给出错的年份。这就是我不用一个阈值、而用两个阈值的原因。

如果他们继续追问

  • 两档设计。 高于高线,直接返回缓存答案并跳过检索。落在中间带时,照样跑完整条流水线,但把旧答案作为提示传进去。中间带既拿到了价值,又不至于把答案押在一个相似度分数上。
  • 危险的失败是无声的。 缓存未命中只是多花点钱;而一次错误命中会产出一个错误答案,日志里却没有任何报错。所以真正要看的指标是错误命中率,不是命中率。
  • 阈值到底该怎么定: 构建一份带标注的问题对集合——同意图的和不同意图的——再从那条曲线上挑切分点。手挑一个数字只是占位,我会这么如实说。
  • 失效。 如果底层文档被重新入库,所有由它们派生出的缓存答案都值得怀疑。这需要一个版本戳。
3

用户问「那他们的分红呢?」——这句话里没有任何东西说明是哪家公司。你怎么处理?

这样说

这就是 指代(coreference) ——这句话指向的是之前提到过的东西,而不是把它说出来。如果你原样送去检索,你就是在没有公司、没有年份的情况下搜「dividends」这个词,结果拿回五家公司的噪声。

有两种修法。你可以让模型结合历史把问题重写一遍,它什么情况都能处理,但要多一次调用,而且可能幻觉出一家从没被提过的公司。或者你把你关心的那些具体实体往下带,这很便宜也可预测,但只覆盖你列出来的实体。

我选了便宜的那个,因为在这个领域里,歧义几乎永远落在同样两个槽位上:哪家公司、哪个财年。我扫描最近的消息找股票代码和年份,如果当前问题缺了其中之一,就把找到的补上去。

它是刻意做窄的,而且我会为此辩护:它是确定性的、可测试的、不增加延迟,而且如果什么都没找到,它就什么都不改。

如果他们继续追问

  • 最近的优先。 我倒着扫描历史,这样如果用户从 BHP 换到了 Rio,往下带的就是 Rio。
  • 只补缺失的部分。 如果问题里已经点名了公司和年份,我完全不碰它——不写状态,也没有风险。
  • 它在哪里失效: 「把那个和另一个比一下」没有可复制的实体,而指标层面的指代(「那利润率呢?」)也没有覆盖。诚实的升级路径是改用模型重写,同时把实体延续留作确定性的兜底。
  • 补全后的问题也会被复用于缓存查找,这样查找和之后的保存用的是同一段文本——否则你会用一个键保存、用另一个键搜索,永远命中不了。

Level 3 · 在本项目中

动手细节

具体文件,具体数字。这里是你证明自己是真造过、而不是读到过的地方。

1

带我过一遍你这个聊天机器人的记忆分层。

这样说

四层,而且每个请求在任何检索发生之前,都会按固定顺序穿过它们。

第一层,持久会话状态。整个图状态在每一步之后都会以线程 id 为键检查点写入 PostgreSQL。所以一次对话能扛住服务器重启,而且我可以精确重放系统在每一步看到了什么——这既是记忆能力,也同样是一项审计能力。

第二层,上下文压缩。一旦历史超过十条消息,较早的那些会被摘要成一条笔记,最近四条逐字保留。

第三层,实体延续。我从最近的消息里抽出公司股票代码和财年,如果当前问题缺,就补上去。

第四层,语义答案缓存。过往的问答对存在它们自己的向量集合里,和年报切块分开。相似度高于 0.95 时我直接返回存下来的答案、完全跳过检索。在 0.80 到 0.95 之间,我跑完整条流水线,但把旧答案作为上下文喂进去。

如果他们继续追问

  • 涉及文件: graph.py 接上 Postgres checkpointer, nodes/compress_context.py 负责压缩, nodes/memory.py 负责实体延续和缓存查找, memory/semantic.py 管缓存存储本身。
  • 关键数字: 压缩阈值 10 条消息,保留最近 4 条,实体扫描窗口 6 条消息,缓存分档 0.95 与 0.80,连接池上限 10。
  • 顺序是刻意安排的: 先压缩,好让实体扫描读到一份干净的历史;再补全,好让缓存查找用的是补全后的问题;缓存放最后,这样一次命中就能跳过下游的一切。
  • 两个彼此独立的向量集合 — reports 放源文档, conclusions 放缓存答案——这样系统永远不可能把自己过去的答案当成年报来引用。
2

你的缓存里存的是问题的向量,而不是答案的。为什么?

这样说

因为查找的时候我手上只有一个问题。如果存下来的向量代表的是一个答案,那我就是在拿问题去比对答案,而这两段文本即便本就配对,看起来也很不一样。

问题短、是疑问句;答案长、满是数字和限定语。它们的向量落在不同区域,所以相似度分数会变得嘈杂、不可靠。

所以我把问题存为可检索内容,把答案和它的来源作为元数据挂在旁边。这样就变成了问题对问题的匹配,而这正是我真正想要的那次比较。

如果他们继续追问

  • 存下来的问题是 补全后的 问题,所以一个被解析成「dividends [BHP, FY2024]」的追问,日后有用户把完整问题直接问出来时也能被检索到。
  • 缓存的来源引用是和答案一起存的,所以一次缓存命中依然会给用户展示和实时答案相同的引用列表。丢掉引用的缓存命中,在用户看来就是一句没有出处的断言。
  • 另一个选择——两边都嵌入、两边都搜——会让存储翻倍,还需要一条融合规则。在问题对问题的命中率被测量出来并被证明不够之前,不值得做。
3

什么东西能阻止一个坏答案被缓存下来、然后一直被端出去?

这样说

写入是有闸门的,不是自动的。只有同时满足两个条件,答案才会被保存:质量评分器判它通过,并且有据性(groundedness)检查没有返回「no」。

有据性的意思是,答案里的每一个事实都能在实际检索到的文档中找到。如果这项检查失败,答案仍然会发给用户——带一条可见的警告——但它绝不会被写进记忆。

还有一个我挺得意的细节。警告文本是在保存 之后 才追加的,所以缓存下来的是干净的答案。否则,未来的用户会收到一条陈旧的告诫,而那条告诫和他的问题毫无关系。

总的原则是:读路径乐观,写路径悲观。缓存未命中很便宜。缓存被投毒可不便宜。

这就是把三个主题串起来的那个答案 缓存写入由评估器把关,而这个评估器同时也是护栏。如果他们让你在代码里指出一个记忆、评估和护栏交汇的地方,就是这里—— nodes/answer.py,其中的 save_conclusion 调用被 grade == "pass" 和 grounded != "no"守住。
如果他们对记忆这块反驳 —— 那些我会抢在他们发现之前自己点出来的缺口

它今天做不到什么

  • 没有过期,也没有失效机制。 如果年报被重新入库或被重述,由旧版本派生出来的缓存答案依然在线上活着。没有任何东西把一条缓存记录和产生它的那次入库运行关联起来。
  • 缓存是全局的。 没有按用户划分的命名空间。在银行里这是硬性阻断项:一个客户的答案绝不能被另一个客户触达。我的项目没有客户特定数据,所以这一点还没咬到我——那是运气,不是设计。
  • 阈值是手挑的。 0.95 和 0.80 靠的是判断,不是在一份带标注的同义改写问题对上拟合出来的。我没有实测的错误命中率。
  • 没有命中率和错误命中率的埋点。 我目前答不上来「缓存多久触发一次,其中又有多少次是错的?」

如果放到企业级规模,我会先建什么

  • 给每条记录加命名空间, 按租户和用户划分,并且在存储层强制执行而不是在应用代码里,这样漏掉一个过滤条件也不会泄漏。
  • 版本戳加上存活时间(TTL)。 每条记录都记下它派生自哪个入库版本;一次新的入库会让受影响的记录失效,而不是只靠年龄来判断。
  • 用实验来调阈值。 几百对带标注的问题,然后从精确率曲线上挑切分点——而且每当向量模型换掉时都要重新检查一遍,因为换模型会悄无声息地让每一个分数都移位。
  • 把它埋点起来: 命中率、分档分布,再加上对命中样本的人工抽查,这样错误命中率一上升就能被看见,而不是等客户发现。
  • 存之前先脱敏。 任何来自客户的文本都应该在写入路径上剥掉个人数据,而不是在读取路径上。

2 · 评估

「针对概率系统的严谨评估策略,包括离线评估、线上实验」——而且这个岗位明确要求塑造该职能的「评估方法论」。

Level 1 · 架构

Lead / Senior

这是招聘启事最在意的一节。他们要招的是一个来定标准的人,所以你要以一个已经定义了什么叫「好」的人的身份来回答,而不是一个跑脚本的人。

1

一个系统每次运行都可能给出不同答案,你怎么评估它?

这样说

第一步是别再问「这个答案对不对」,改成问「这个系统在一组固定情境下的行为是什么样」。单次运行几乎什么都说明不了,因为同样的输入可以产出不同的输出。

所以我把能固定的都固定住。一组期望已知的固定用例。凡是检查能做成确定性的,就做成确定性的。而在我必须判断某些主观内容时,我评的是答案的某个属性,而不是去匹配文本。

第二步是先评组件,再评整体。如果我只做端到端测试,一次失败只告诉我系统错了,却不告诉我错在哪。在我的项目里,我可以单独测路由、单独测检索、单独测评分器,这样一次回归就能指向某一个组件。

然后是两个阶段,不是一个。离线评估是发布前跑一组固定测试集——它能挡住已知的回归,但没法告诉你真实用户的情况。线上评估是发布后在真实流量上测量——现实是在那里到场的。两者都需要,而且离线集应当由线上发现的问题来喂养。

如果他们继续追问

  • 方差究竟从哪来: 模型中的采样、检索器打平时的非确定性、外部数据的变化,以及供应商悄悄更新模型。只有第一项能靠把温度设成零解决——其余的你只能去测量。
  • 重复运行是一种测量,不是麻烦。 把一个用例跑五次、看它的离散程度,就能知道这个行为有多稳定。一个在通过和失败之间反复横跳的用例是一项发现,不是一个重跑一下就算了的偶发失败。
  • 交付按维度拆开的记分卡,永远不要只给一个数。 单一的聚合分数会掩盖这样一笔交易:检索变好了,但拒答变差了。
2

你具体测什么?把维度说给我听。

这样说

我把流水线拆成若干阶段,每个阶段按它自己的标准来测,因为「答案质量」根本不是一个单一属性。

路由:系统有没有正确决定是该检索、该搜网、该反问一个澄清问题,还是直接作答。这是一个有干净标签的分类问题,所以又便宜又精确。

检索:给定一个已知正确数字的问题,包含那个数字的文档有没有到达作答那一步,以及排在第几位。这项测量全程不需要模型参与。

有据性:答案里的每一项主张是不是都被检索到的文档所支持。这就是幻觉指标,也是在受监管场景里我最在意的一项。

边界行为:面对一个系统本就不该回答的问题,它有没有恰当地拒答,而不是编一个出来。除此之外还有每次查询的延迟和成本,因为一个没人愿意等的准确答案不是产品。

对了,但理由是错的

端到端的答案看起来是对的,但检索器根本没找到出处,模型是凭训练数据答出来的。它能通过答案质量检查,却过不了有据性,而且等下个季度数字一变就会失败。

为什么组件级指标能抓到它

检索召回率是单独测量的,所以「证据从未到场」会显示成一次检索失败——哪怕最终文本碰巧是对的。

如果他们继续追问

  • 拒答有两类错误, 而且你必须两个都报:本该拒绝却回答了,以及本可以回答却拒绝了。只优化其中一个,就是你造出一个又没用又过度保守的助手的方式。
  • 引用正确性和有据性是两回事。 一个答案可以完全有据,却指向错误的页码——而这比含糊其辞更快地摧毁用户信任。
  • 面向银行,我还会加上对拒答分类体系本身的覆盖:金融建议、个人数据、超出范围,是三种不同的拒答,各自要求三种不同的回应。
3

你改了一个参数,分数上去了。你怎么知道这是真实的改进,而不是噪声?

这样说

三件事:隔离这次改动、在同一批用例上做对比、并且对样本量能支撑多少结论保持诚实。

先隔离。如果我改的是检索,我就把模型完全从链路里拿掉,直接给检索打分。否则模型自身的方差会比我想看到的效应还大。

然后做配对比较,而不是比平均值。同一批用例,两种配置,我看的是逐个用例的变化——多少个变好了,多少个变差了,多少个是以前漏掉、现在找到的。均值可以上升,而个别用例在回归,而事件(incident)恰恰就是由这些回归造成的。

然后我把界限明说出来。大约十几个用例,我能看出方向,看不出显著性。我会把它称作「可以继续推进的证据」,而不是「证明」——而且如果这个决策代价很高,我会先去标注更多用例再上线。

如果只说一句话,就说这句 「n 在十二左右,我能声称方向,不能声称显著性。」招聘启事要求「扎实的统计学基础……量化不确定性」。主动说出自己证据的界限,是你确实具备这些基础的最清晰的信号。

如果他们继续追问

  • 配对检验是对的工具, 因为用例才是变异的单位。比较两个独立的平均值等于扔掉了配对关系,要达到同样的置信度需要多得多的数据。
  • 当心那些只是看起来变好的指标。 把候选池放宽几乎总能提高召回率——诚实的问题是重排(rerank)之后的精确率有没有守住,以及成本和延迟动了多少。
  • 预先声明什么情况下你会否掉这次改动。 看到数字之后再定阈值,正是团队说服自己接受回归的方式。

Level 2 · 实现

Mid-level

这套评估框架实际是怎么搭的:每次提交跑什么,什么最花钱,以及你怎么让评判者保持诚实。

1

离线评估、线上实验——区别是什么,为什么两个都需要?

这样说

离线指的是一组带预期结果的固定用例,按需运行。它可复现、便宜、安全,所以最适合用来卡发布门禁。它的弱点是里面只包含你已经想到过的失败。

线上指的是在真实流量上测量系统。通常就是一次 A/B 测试(A/B test):一部分请求走新版本,其余走当前版本,然后比较结果。它的强项是能发现你从没设想过的问题;它的弱点是在你学习的过程中,会有一部分用户拿到更差的版本。

这两者连成一个闭环,而这个闭环才是真正的方法论。线上发现一个新失败,这个失败变成一条离线用例,离线套件因此更强。如果你的测试集只在工程师灵光一现时才增长,它就会逐渐偏离用户真正在做的事。

在真正做 A/B 之前,我会先跑影子模式(shadow mode)——新版本处理真实流量,但它的输出只写日志、不展示给用户。你能拿到真实的分布,同时对客户零风险。

如果他们继续追问

  • 线上的指标难题 在于你在线上很少有标准答案。所以你得倚重代理指标——重试率、拒答率、点踩、放弃率、转人工——再加上抽样人工复核。
  • 给实验本身也上护栏: 一旦拒答率或延迟越界就自动回滚,这样一个坏变体不至于跑一整夜。
  • 按用户随机分流,而不是按请求, 否则同一个人会在一次对话里看到两种不同的行为,体验会前后不一致。
  • 在银行里,面向客户的 AI 做线上实验是一件治理层面的事,不只是工程层面的——它需要审批、一个明确写下来的假设,以及事先约定好的退出规则。
2

你用一个模型去给模型的输出打分。你怎么知道这个评分器是对的?

这样说

这正是 LLM 评判(LLM-as-judge)老老实实讲的薄弱点,而答案是:评判者和其他任何组件一样,它自己也需要被评估。

具体做法是:我抽一批用例,自己标注一遍,然后测量评判者和我意见一致的比例。如果一致性很差,那评判者的结论就不构成证据,我也不该拿它去发版。

然后我把评判者的活儿做得尽可能窄。它从不在一个量表上给「质量」打分。它拿到的是问题、检索到的文档和答案草稿,返回的是一个严格的结构——通过或不通过,外加一份它无法核实的主张清单。受约束的输出比自由文本的意见稳定得多。

还有两点很重要。我会明确告诉它哪些差异不算「无依据」——单位不同、四舍五入、会计记法——否则一个本来完全有据的答案会被不停地标红。而且我会刻意设定它的严格程度:在通过/不通过这一档我让它宽松一些,因为那道闸门存在的意义只是拦住内容空洞的答案。

如果他们继续追问

  • 已知的评判者偏差: 偏好更长的答案、偏好和自己相近的写作风格,以及比较两个候选时的位置效应。位置偏差可以通过交换顺序再问一遍来部分抵消。
  • 在提示词里把两件任务拆开。 「这个答案有用吗」和「每一项主张都有依据吗」是两种不同的判断;在一次无结构的回复里同时要这两样,会让两样都变差。
  • 永远先做确定性检查。 凡是正则表达式或数值比较能验证的,都不该送给模型——更便宜、更快,而且它不会改主意。
  • 评判者是一个你控制不了的依赖。 如果供应商更新了模型,你的评分基线就漂移(drift)了。这就是要固定版本、并在每次模型变更时重跑一致性检查的理由。
3

怎么把评估放进 CI 而不产生一笔巨额账单?

这样说

按成本把它分层,每一层按它负担得起的频率来跑。

最便宜的一层完全不调用任何外部服务。它把合成输出喂进真实的评估器代码。这并不能证明系统能工作——它证明的是我的用例格式正确、我的打分逻辑正确。它免费又快,所以每次提交都能跑。

中间那一层跑真实组件,但只跑少量用例,而且只走仍然有意义的最便宜路径。对路由,我只跑路由相关的节点,而不是整张图。这是 pull request 上的冒烟测试。

最贵的一层是对着真实服务和真实索引跑完整套件。它按计划任务跑,不是每次 push 都跑,而且结果会和一份存下来的基线比对,一旦下滑就能看见。

我会立成标准的一条规则是:发布门禁必须是确定性的。如果一次构建可能因为模型今天心情不同而失败,工程师就会学会一直重跑到变绿,这道门禁也就不再有任何意义了。

Mock 层 —— 每次提交

不联网,不调模型。校验用例的 schema 和评估器逻辑。失败得响亮且确定。成本:零。

真实层 —— PR 冒烟,每夜全量

真实模型和真实索引,PR 上限制用例数量,全量套件按计划跑,并与一份提交进仓库的基线比对。

如果他们继续追问

  • 按输入哈希缓存模型响应, 这样对未改动用例的重复 CI 运行就是免费的;当提示词或模型版本变化时让缓存失效。
  • 把基线分数提交进仓库。 一个躺在没人看的日志里的数字不是回归门禁;一个躺在文件里、会被 diff 显示出变化的数字才是。
  • 用例需要有归属人和存在理由。 我套件里的每个用例都带一个 why_it_matters 字段,加载器会拒绝没有它的用例,所以没人能塞进一个目的说不清的用例。

Level 3 · 在本项目中

动手细节

把一个实验讲透,比罗列一串功能有价值得多。下面这个 BM25 的故事是你整场面试里最强的单个答案——练到顺为止。

1

你的评估套件实际覆盖了什么?

这样说

四个套件,外加一个独立的检索测评框架。路由、检索、计算和边界行为,一共大约四十条用例,存成按行分隔的 JSON 文件。

每条用例都有 id、查询、期望结果,以及一行 why_it_matters 说明它能抓住什么回归。加载器拒绝运行任何缺少其中一项的用例,所以这个套件不会悄悄塞满没人看得懂的用例。

一条命令行背后有两种运行模式。Mock 模式不调用任何外部服务,只检查我的用例和评估器是否正确。Real 模式跑真实节点,而且我可以把范围收窄到单个套件、限定用例数量,或者指定某一条用例 id,这正是在调试时把 real 模式成本控制住的关键。

有一个设计取舍我想特别提一下:real 模式跑的是仍然有意义的最便宜路径。路由类用例直接执行路由节点和查询改写节点,而不是整张图,这样路由回归能被抓住,却不用为检索和合成付钱。

如果他们继续追问

  • 各套件规模: 路由 8条,检索 8条,计算 6条,边界 6条,另有 12 条数值标准答案用例供 A/B 测评框架使用。规模不大,这一点我会不等问就主动说。
  • 边界用例才是有意思的那些: 空查询必须完全不触发检索,而「去年的数字是多少?」必须产生一个澄清问题,而不是一次猜测。
  • 加一个套件只需要在注册表里加一条记录, 把名字映射到一个用例加载器和一个评估器,所以这个框架的形状不会抗拒新增维度。
  • 失败会逐条打印并附上原因, 只要有任何一条失败,进程就以非零码退出——所以它已经长成了一个 CI 门禁该有的样子,尽管还没真接进 CI。
2

跟我讲一个结果出乎你意料的实验。

这样说

我的关键词检索器是按字面匹配的,所以大写 R 的「Revenue」和「revenue」是两个毫不相干的词,而带逗号的「revenue,」是第三个。我写了一个正经的分词器把它们归一化。

在读这个库的源码时,我发现了一件我没料到的事。 BM25 本应自动处理常见词:一个出现在大多数文档里的词会拿到很低的权重,所以「the」应该几乎一文不值。这个公式对这类词能算出负权重,而这个库拒绝负权重——它会把它们抬到一个下限值。

结果和本意完全反了。「The」出现在我 84% 的切块里,它的真实权重是负的,而那个下限把它抬到了 1.81——比真正有信息量的「Group」的 1.54 还高。受影响的只有八个词,但这八个词几乎出现在每一个自然语言问题里。

我实测过。对于问题「what was the revenue of the group in 2024」,排名第一的文档得分中有 59% 来自 what、was、the、of、in 这几个词。把这些词去掉之后,新的前五名文档与原来的前五名零重叠。这不是排序上的微调——结果原本是由填充词决定的。

我从中得到的教训,也是我会带进团队的:教科书的说法在算法层面是对的,在实现层面是错的,而只有读源码、实测真实得分,才能看出这个差别。

这个回答为什么有效 它展示了源码级调试、可量化的前后对比,以及一个可以推广的结论。它同时也回答了招聘广告里「快速开展实验……什么有效、什么失败、以及为什么」这一条,而不需要你自己去声称。

如果他们继续追问

  • 归一化后的数字: 匹配「revenue」的文档数从 221 增加到 278,提升 26%;词表规模从 37,933 缩小到 23,676,下降 37.6%。词表变小的意义不在于省内存——而在于文档频率变正确了,被虚高的权重回落到了它们真实的水平。
  • 金融文本需要格外小心: 我只从 token 的首尾剥离标点,所以 $1.2B 和 16% 能完整保留,而 revenue, 和 (Group). 会被清洗掉。带千分位分隔符的数字会被索引两次,分别是 53,658 和 53658,所以用户输入任何一种写法都能匹配上。
  • 我自己的改动引入的 bug: 一个完全由常用词组成的问题,比如「what is it」,分词(tokenise)之后什么都不剩。空查询会让每一篇文档都得零分,而库随后会毫无报错地返回任意二十篇文档。我让分词器在过滤会清空列表时回退到未过滤的 token,并在自检里用断言固定下来。静默失败比抛异常更糟。
  • 部署上的后果: 分词器是以函数引用的形式存进索引文件里的,所以模块路径一旦改名,此前构建的每一个索引都会失效。我把这一点写在了文件顶部,因为它正是后来的维护者最容易无意中打破的那类约束。
3

那检索这个改动本身,你又是怎么衡量的?

这样说

我搭了一个小的 A/B 测试台,直接调用检索节点,完全不碰语言模型,所以两次运行之间唯一的变量就是检索配置。

标准答案(ground truth)是我确认过确实存在于已入库文本中的精确数字串——例如 BHP FY2024 营收的 55,658,以及它基础 EBITDA 的 29,016。只要返回的文档里包含这个数字,该用例就算命中,同时我记录它第一次出现的排名。

我会报告最终截断范围内的命中数、前五名内的命中数,以及命中项的平均排名。然后逐个用例打印它是改善、退化、新命中还是新丢失——因为整体指标可以变好,而个别用例反而变差,而退化的那些才是我真正想看到的。

两组配置分别是旧的——候选池八篇、不做重排——对比新的:候选池二十篇,再接一层 cross-encoder 重排(rerank)压回八篇。

一个实际细节:重排器是带每分钟 token 限额的付费 API,所以测试台在用例之间固定停顿来自我限速。没有这一步,比较会因为限流而失败,看上去却像是质量结果。

如果他们继续追问

  • 为什么先放宽再收窄: 融合这一步只按位置排序,从不读正文,所以一个含有精确数字的切块(chunk)可能排在主题相近的样板文字下面。更宽的候选池抬高了「能被找到」的天花板;cross-encoder 随后逐个候选对照问题去读,决定哪八篇留下。下游成本不变,因为最终数量是一样的。
  • 用精确字符串匹配作为标准答案是刻意的。 它严格,而且没有争辩空间。它也会低估那些换一种方式表达同一数字的文档——这是指标本身的已知局限,不是检索器的问题。
  • 两组配置之间必须清一次缓存, 因为候选数量被固化在已加载的索引对象里。漏掉这一步会让第二次运行悄悄复用第一组配置——正是那种会得出自信而错误结论的 bug。
  • 诚实的局限: 十二个用例,每个只跑一次。看方向,不看显著性。
如果他们在评估上追问 —— 我会在他们发现之前先自己点出的缺口

目前还做不到的事

  • 评估没有进 CI。 仓库里唯一的自动化工作流是部署。这套评估套件的形态是按「门禁」设计的——逐用例报告、非零退出码——但没有任何东西会自动跑它,所以也没有任何东西能挡住一次退化合进 main。
  • 没有存下来的基线。 分数只打印到控制台。没有提交进仓库的文件可供 diff,所以「这次是不是变差了」只能靠有人记得上周的数字。
  • 评判器没有被验证过。 我从没测过有据性(groundedness)评分器在相同用例上与人工判断的一致率,所以我说不出它的错误率。
  • 完全没有线上测量。 没有 A/B 测试,没有影子模式(shadow mode),没有漂移(drift)检测,也没有对线上答案的抽样人工复核。
  • 样本量小。 大约四十个离线用例、十二个数值用例。够抓出明显的退化,不够支撑统计结论。

在企业规模下我会先做的事

  • 立刻把分层评估接进 CI: 每次提交跑 mock 套件,PR 上跑有限的真实冒烟测试,每晚对照提交进仓库的基线跑完整真实套件。这大概是一天的工作量,却是清单上价值最高的缺口。
  • 从生产流量里养大黄金数据集。 采样真实问题,尤其是那些被重试、被拒答或被点踩的,打上标签,再晋升进离线套件。这才是让测试集不至于和现实脱节的办法。
  • 做一次评判器一致性研究 在几百个人工标注样本上进行,公布一致率,并在评判模型或提示词(prompt)变更时重新跑一遍。
  • 漂移监控要盯分布,而不只是均值: 检索得分分布、拒答率、有据性通过率、答案长度、工具调用构成。形状的变化,是上游出了状况最早的信号。
  • 在任何面向客户的 A/B 测试之前先跑影子模式, 并事先约定好停止规则,以及在拒答率或延迟越界时自动回滚。

3 · 护栏

「可解释、可审计,并符合 NAB 的负责任 AI 原则」——外加可观测性、漂移检测与事件分类。

第 1 层 · 架构

Lead / 资深

银行想问的不是你知不知道「护栏」这个词。他们想知道的是,你能不能一层一层地决定:当某道检查本身失败时,会发生什么。

1

在银行里,你会怎么为一个面向客户的 LLM 产品设计护栏?

这样说

做成边界上的分层,而不是末端的一道过滤。出答案之前放一道检查,是大多数人最先想到的设计,但那太晚了——到那时你已经把全部算力都花完了,能做的只剩下拦截。

我在请求经过的每一个边界上放一道检查。输入端:这个问题在服务范围内吗,清晰到值得一试吗。检索端:返回的东西真的相关吗,用户问到的每一项我都找到了吗。生成端:草稿里的每一条陈述都有证据支撑吗。输出端:这里到底有没有实质性的答案,引用能不能解析出来。

有两条规则我在每一层上都坚持。便宜且确定的检查排在昂贵且带概率的前面——如果一个长度检查或一次查表就能拦住,就别花一次模型调用。以及,每一层都把自己的判定写进运行状态,这样决策是被记录下来的,而不是靠代码走了哪条分支去隐含推断。那条被记录下来的轨迹(trajectory),正是让系统可审计的东西。

末端一道过滤

整条流水线跑完,最后一道检查拦下答案。每一次失败都要付掉全部算力预算,而唯一可用的回应就是拒答。也无从诊断到底哪里出了问题。

边界上分层

每一阶段先校验再往下传。失败是廉价的、局部的、有名字的——所以系统可以追问、重试、降级或拒答,而日志会说明是哪一层触发的。

如果他们继续追问

  • 有四种回应,不是一种。 拒答、追问澄清、带可见提示地作答,或用更好的查询重试。把每一次护栏失败都当成拒答,正是做出一个用户想方设法绕开的系统的办法。
  • 把策略和机制分开。 什么算超出范围,是业务与风险上的决定,应该让非工程人员也能审阅;怎么执行,才是我的事。如果这两者纠缠在同一个提示词里,风险部门就没法审计。
  • 点名你要防的是哪几类危害 ——错误的数字、无牌照的金融建议、泄露的个人数据、超范围作答。它们需要不同的控制手段,而一个笼统的「安全检查」哪一类都盖不住。
2

你的护栏本身抛了异常。你是 fail open 还是 fail closed?

这样说

这取决于是哪一层,而重点在于这是我按层逐个刻意决定的,而不是让异常处理器替我决定。

我的规则是这样。如果这一层是精确率上的改进——它让一个好答案变得更好——那就 fail open,因为失去它只是质量下降,而卡在它上面会让整个服务不可用。如果这一层是正确性的底线——没有它,一个错误或空的答案就会到达用户——那就 fail closed。

所以在我的项目里,两个基于模型的评判器都是 fail open。如果相关性评分器出错,我把全部检索到的文档都放过去,而不是一篇都不放。如果有据性评分器出错,我把答案标记为通过,而不是拦下它。两者都是叠在本来就能工作的检索之上的、可选的精确率层。

最后那道检查不一样。它是确定性的,不发任何网络调用,所以本质上没什么可失败的——而且它在构造上就是 fail closed:没有实质内容,就一定走兜底路径。

在银行里我会补上的一点是:fail open 必须是响的。悄无声息地降级,正是一个系统带着关掉的安全层跑了整整一周的原因。每一处 fail open 都需要一个指标和一条告警。

这是资深级别的回答,面试官会专门听这一点 大多数候选人回答「fail closed,安全第一」就停住了。给出一条规则、按层区别应用、再补一句「fail open 必须有埋点」,这就是运维过系统的人和只搭过系统的人之间的差别。
3

你怎么让一个 LLM 系统可解释、可审计?

这样说

我会把三件常被混为一谈的事分开:用户能不能看到为什么、工程师能不能还原发生了什么、以及审阅者能不能在几个月后拿出证据。

对用户来说,解释必须在答案本身里。每一个来自报告的数字都带一个引用标记,能解析到具体的公司、财年和页码——这样这条陈述可以对着原文核对。而当验证步骤无法确认某件事时,答案里会直接写出来,不是写在日志里。

对工程师来说,每一个决策都写进运行状态,而状态在每一步都做检查点。所以我能还原的不只是最终答案,还包括选了哪条路由、哪些文档通过了评分、评判器说了什么。可还原比可记日志更强,因为日志是散文,而状态是结构化的。

对审阅者来说,我项目里的缺口在于:这条轨迹是运维存储,不是审计留痕。它不是不可变的,也没有留存策略。在受监管的环境里,我会把决策轨迹写到只追加(append-only)的存储里,设定明确的留存期,并把它当成与会话状态完全不同的一份产物。

如果他们继续追问

  • 置信度必须是算出来的,而不是装饰。 我的置信度是从有据性判定推导出来的——所有陈述都有支撑、部分没有、还是很多没有——所以它对应的是一次真实跑过的检查,而不是模型自报的确定程度。问一个模型「你有多确定」,得到的是一个没有指称对象的数字。
  • 未被引用的来源会被移除、编号会被重建 而且是在答案展示之前完成,这样参考文献列表里永远不会出现答案没用到的东西,标记编号也始终是连续的。
  • 事件分类需要事先定好分类体系: 数字错误、无支撑陈述、错误拒答、数据泄露、服务不可用。没有事先商定的类别,每一次事件都是孤例,什么也统计不出来。

第 2 层 · 实现

中级

机制层面:你怎么检测幻觉,怎么在拒答与保留说明之间做选择,以及怎么让重试循环不至于失控。

1

你怎么阻止模型产生幻觉?

这样说

我会温和地对「阻止」这个词提出异议。语言模型预测的是看起来合理的文本;自信地给出一个错误数字,不是一个你能打补丁修掉的 bug。所以目标不是防止,而是检测与遏制。

检测之所以可行,是因为在检索系统里我有东西可以对照。我把草稿答案连同它当时拿到的那些确切文档一起交给验证步骤,问一个很窄的问题:这个答案里的哪些陈述在这些文档里找不到。

接下来的遏制有三档。如果无支撑的内容很多,我就针对失败的那些具体陈述重新做定向检索。如果只有一点点无支撑,答案照常发出,但用户会看到哪些陈述未经核实。如果什么都没有无支撑,就干净地发出去。

还有一点值得提:验证器还会分类「为什么这条陈述无支撑」。它是由文中确实存在的数字算出来的?是从趋势里估出来的?还是跟文档毫无关系?第一种往往是合理的,只需要把推导过程展示出来。最后一种才是真正的幻觉。把这三种一视同仁,会丢掉用户需要的信息。

如果他们继续追问

  • 验证器需要帮忙,才不会误报。 金融表格把单位放在表头行、数字放在数据行,所以原始文本写的是「29,016」,而答案写的是「US$29,016 million」。如果不告诉验证器该怎么匹配这两者,它会不停地把正确答案标红,而一个在正确答案上响的警告,只会训练用户忽略警告。
  • 容差必须写清楚: 四舍五入、数量级前缀、负数的括号写法、指标名称的各种变体,以及财年的不同写法,全都算作有支撑。
  • 有据不等于正确。 一条陈述可以和文档完全吻合却仍然是错的,只要这份文档在入库时就被读错了。这道检查约束的是「捏造」,不是「真相」——我会把这点直说,而不是夸大它。
2

系统什么时候该拒答,什么时候该追问,什么时候该带着提示作答?

这样说

这是三种不同的情形,把它们搞混是产品失败,而不是技术失败。

当请求超出系统的用途时,拒答。有人向一个财报助手要一首诗,应该得到一条清楚的边界说明,以及它究竟覆盖什么——而不是一首诗,也不是含糊其辞。

当请求在范围内但没说清楚时,追问。「去年的数字是多少」是个合理的问题,只是缺了主语。猜测会产出一个关于错误公司的自信答案;问一句短问题只花掉一个回合,却能答对。

当我有真实证据但无法全部核实时,带提示作答。这时拒答等于扔掉一个大体有用的答案;隐瞒缺口则是不诚实。把答案给出来、同时点名哪些部分未经核实,才是把用户当成一个能自己判断的人。

而且提示必须具体才有用。每个答案上都挂一句「本内容可能不准确」是噪音。在真正失败的那一条陈述上写「该数字无法与检索到的文档核对上」,才是信息。

如果他们继续追问

  • 拒答必须是可行动的。 我的拒答会列出覆盖的公司、财年和指标,并给出两个示例问题。一句干巴巴的「我帮不了你」只会让用户去猜。
  • 追问本身也有代价。 一个动不动就要求澄清的系统让人筋疲力尽,所以指令是:只有在检索确实无法进行时才追问——问题只是不够完美时,就先试着答。
  • 部分覆盖值得单独处理。 如果有人问了五家公司而我只找到四家,答案应该说明缺的是哪一家,而不是悄悄只答那四家。
3

检查失败时你的系统会重试。你怎么让这个循环不失控?

这样说

三条约束。一个硬上限、一个变化了的输入,以及重试耗尽时一个明确的结局。

上限是最简单的部分——状态里放一个计数器,最多重试两次。因为状态就是整张图共读的同一个对象,所以只有一个计数器,而不是每条分支各有一个。

第二条是大家常漏的。用同样的输入重试,只会得到同样的答案,白花钱。所以每次重试都必须改变点什么。在我这里重试是定向的:验证失败的那些陈述变成新的检索词,而这些陈述所属的公司会被标记为「尚未找到」,好让下一轮专门去找它们。

第三条是:重试用尽是一个有明确路径的正常结局,不是错误。它会走到兜底回复,说明系统覆盖什么。无界循环和未处理的耗尽,是同一个 bug 的两端。

如果他们继续追问

  • 重试的触发条件是明确的: 答案被评为不通过、某个被问到的公司没有任何支撑文档,或者出现两条及以上无支撑陈述。一条无支撑陈述不值得重试——它值得一句提示。
  • 每次重试的成本会成倍叠加 尤其是在扇出(fan-out)上。五家公司并行检索,再重来一次,就是十次检索调用外加重排。这个上限既是正确性决定,也是预算决定。
  • 在生产环境里,我会为每一种重试原因打一个指标, 因为某一种原因的重试率上升,是检索质量已经漂移的早期信号——早于任何用户投诉。

第 3 层 · 在这个项目里

动手细节

按顺序说出每一层。然后给出一个细节,证明你想得比这份工作要求的更深。

1

带我过一遍你应用里的护栏链路。

这样说

六个检查点,按请求遇到它们的顺序。

第一,范围。路由步骤对问题分类,任何与这五家公司无关的问题都会得到一条固定回复,说明系统覆盖什么。第二,清晰度。如果问题模糊到无法据以检索,图会去问一个澄清问题,而不是猜。

第三,文档相关性。检索到的切块会被评分,像治理样板文字或审计师报告这类内容会被丢掉,除非问题本来就是问它们的。这一步还会修正每家公司「找到与否」的标记,因为只有当一家公司有文档通过了评分,才算真的找到了。

第四,有据性。草稿答案会逐条陈述对照它拿到的文档做检查,产出无支撑清单和重试信号。

第五,最终闸门。一道确定性检查,看看究竟有没有实质内容——它坐在通往答案的每一条路径上,包括那条从未碰过检索的直答路径。失败则路由到兜底回复,说明覆盖范围。

第六,输出整形。移除未被引用的来源并重排标记编号,并附上一个由有据性判定推导出的置信度等级;等级不高时,带上一句具名的提示。

如果他们继续追问

  • 按顺序对应的文件: nodes/retrieve_decision.py, nodes/clarify.py, retrieval_nodes/grade_docs.py, retrieval_nodes/grade_answer.py, nodes/guardrails.py 配合 nodes/fallback.py,以及 nodes/answer.py 配合 utils/citation.py。
  • 为什么最终闸门要覆盖每一条路径: 直答路由完全跳过检索,所以如果没有一道共享的闸门,一个空的直答就会未经检查地到达用户。把检查放在边界上而不是放进每条分支,意味着新增的分支会自动继承它。
  • 兜底消息是带诊断信息的。 它会区分「什么都没检索到」和「检索到了文档但无法综合成答案」,因为这是两种不同的失败,后者意味着检索没问题、综合有问题。
2

给我一个你代码里的护栏决策,是你愿意在设计评审上为它辩护的。

这样说

当验证器报出一条无支撑陈述时,它被要求写一个不超过八个词的短标签,指明指标和上下文——例如「RIO FY2025 revenue」——并且明确要求不要复述那句话或那个数字。

原因是这个标签会作为警告展示给用户。如果验证器回显了完整陈述,那么这条本来是为了标记「可能是编造的数字」而存在的警告,就会把那个数字又一次以文字形式打印在答案里。安全提示反而成了错误数字出现的又一个地方。

一个略读的用户可能只看到数字,而错过它的语境。所以这条警告只说该怀疑哪一条陈述,而不重述那个他们本不该依赖的数值。

这是件小事,也正是我会去找的那类东西:不是这道检查管不管用,而是这道检查自己的输出到达人类时会做什么。

这一条准备好逐字背下来 这是你手上最强的护栏回答,因为它是一个没人要求你做的决定。这就是「实现一条需求」和「对一个界面负责」之间的差别。
3

你的系统在哪些地方是刻意 fail open 的?每一处你都能说出理由吗?

这样说

三个地方,而且每一个都是决定,不是意外。

相关性评分器 fail open:如果那次模型调用出错,我把所有检索到的文档都放过去,而不是一篇都不放。失去这道过滤意味着上下文更嘈杂。而 fail closed 则意味着完全没有上下文,那是把一个降级的答案变成没有答案。

有据性评分器同样 fail open:出错时答案被标记为通过。这是我最不放心的一处,因为它就是幻觉检查,而它的失败是无声的。让它勉强站得住的是确定性的最终闸门仍然会跑;而要让它在生产环境可以接受,需要一条针对错误率的告警——这个我还没有。

重排器也是 fail open:遇到网络错误或限流时,结果保留融合步骤给出的排序。它是叠在一个本来就存在的排序上的精确率改进,所以降级回上一个排序恰恰是对的。

还有一个是刻意 fail closed 的:最终内容闸门是确定性的、不依赖网络,所以空答案永远路由到兜底。底线这一层不能有可用性上的失败模式,而这正是它什么都不调用的原因。

如果他们继续追问

  • 路由步骤是 fail safe,而不是 fail open 或 fail closed: 如果分类器出错,默认是去检索,而绝不因澄清而阻断。多检索一次只是花钱;拒掉一个有效问题,花掉的是信任。
  • 真正的风险是无声的 fail open, 而不是 fail open 本身。这里每一处都吞掉了一个异常。在生产环境里,每一处都需要一个计数器和一条阈值告警,否则系统可以带着实际上已关闭的安全层运行,而看起来完全健康。
  • 我会怎么向风险部门表达这件事: 一张表,列出层、失败模式、方向、影响范围,以及覆盖它的告警。那张表才是交付物,代码不是。
如果他们在护栏上追问 —— 我会在他们发现之前先自己点出的缺口

目前还做不到的事

  • 最终闸门只是一个长度检查。 十个字符的内容。它是防空答案和退化输出的底线,不是内容过滤器——我宁愿自己说出来,也不想让他们自己发现。真正的验证工作发生在前一层,在有据性检查里。
  • 没有提示注入防护。 从 PDF 里抽出的文本被直接放在我的指令旁边。一份含有指令样文本的文档,目前是被当作可信输入的。对公开披露文件来说风险很低;但对任何客户可以自己提供的文档来说,就不是了。
  • 没有个人数据或有害内容处理。 没有检测,写日志前也没有脱敏。这个项目没有客户数据,所以这个问题从没浮出水面——但在 NAB,这是基本要求,不是加分项。
  • 范围控制只靠一次模型调用 没有确定性的兜底,所以一次分类错误是完全没有防护的。
  • 没有限流,也没有不可变的审计日志。 状态做检查点是为了运维,不是为了留存供审阅。

在企业规模下我会先做的事

  • 把最终闸门做成真正的策略层: 答案给出数字时强制检查引用、数值合理性边界、按拒答分类体系归类——同时保留长度检查,作为最便宜的第一道测试。
  • 把检索到的文本当作不可信输入。 入库时扫描,在提示词里把文档内容和指令明确隔开,并且绝不让检索到的文本未经过滤地进入可调用工具的路径。
  • 在写入路径上就脱敏个人数据 ——在写日志之前、在缓存之前、在链路追踪之前——这样下游系统就不必去记着做这件事。
  • 给每一处 fail open 加埋点 配上计数器和告警阈值,并汇总到一块看板上,让「安全层已经报错一周了」不可能被忽略。
  • 事先和风险部门商定事件分类体系 ——数字错误、无支撑陈述、错误拒答、数据泄露、服务不可用——并为每一类定好严重级别和流转路径。类别要在第一次事件之前就定好,而不是之后。
  • 把决策轨迹写入只追加存储 并配上留存策略,与会话状态分开存放,这样审阅者在几个月后仍能还原任何一个答案。

值得记住的数字

具体数字才让回答可信。这些是值得随口就能说出来的。

系统

  • 5 家公司——BHP、RIO、FMG、MIN、NST——覆盖 FY2023–FY2025
  • 候选池 20 + 20,融合后重排压到 8 篇最终文档
  • 融合权重 0.45 关键词 / 0.55 向量
  • 缓存分层 0.95 直接命中, 0.80 上下文种子
  • 压缩触发于 10 条消息,保留 4 条原文,实体扫描 6 条消息
  • 重试上限 2,按公司并行扇出

BM25 实验

  • 「the」出现在 84% 的切块里;真实权重为负,被钳制抬到 1.811 ——高于「Group」的 1.542
  • 只有 8 个词受影响,但它们几乎出现在每一个问题里
  • 59% 的头名文档得分来自填充词
  • 前五名的前后重叠: 5 个中 0 个
  • 匹配「revenue」的文档数: 221 → 278 (+26%)
  • 词表规模: 37,933 → 23,676 (−37.6%)
  • 证据基础: 12 个数值用例——看方向,不看显著性
就算忘光所有数字,也要记住这个结构 问题 → 朴素做法以及它为什么不行 → 我做了什么 → 证明它有效的那个数字 → 它仍然覆盖不到什么。本页每一个强回答都遵循这个结构,遇到没准备过的问题,你也可以在这个结构里即兴发挥。

术语表

上文用到的每一个术语,用大白话解释。如果面试官用了一个你不确定的词,随时可以问「你指的是 X 吗?」——确认一下显得严谨,而不是心虚。

展开全部定义 —— 39 个术语,大白话解释
LLM —— 大语言模型
一个预测「接下来最可能出现什么文本」的模型。它没有真假的概念;它产出的是看起来合理的东西,而这正是本页其他一切存在的原因。
Agent —— 智能体 / 智能体系统
一个能在循环中采取行动的 LLM——搜索、调用工具、检查自己的输出——而不是一次性给出答案。
RAG —— 检索增强生成
先找到相关文档,再让模型只依据这些文档作答。它把「从训练里回忆」换成了「读这份证据」。
Embedding —— 向量
一串代表某段文本含义的数字。含义相近的文本,得到的数字串在数值上也彼此靠近,这正是基于含义的搜索得以可能的原因。
向量库 / 向量数据库
能快速找出与查询向量最接近的那些向量的存储。我用的是 ChromaDB,跑在本地磁盘上。
Chunk —— 切块
一份文档太大,没法作为一个整体做向量化,所以它被切成若干片。每一片就是一个切块,而检索返回的是切块,不是文档。
BM25
一个经典的关键词排序公式。它奖励查询词在某篇文档里出现得频繁,也奖励那些在整个集合中罕见的词。
df / idf —— 文档频率、逆文档频率
有多少篇文档包含某个词,以及由此推导出的权重。罕见词得分高,常见词得分低。上面那个故事里的 bug,就出在对这个权重的钳制上。
Tokenise —— 分词
把文本切成会被索引和匹配的单位。「Revenue,」变成 revenue 就是一个分词器的决定。
Stop words —— 停用词
非常常见的词——the、of、is、what——通常不携带任何检索信号。
混合检索(hybrid retrieval)
同时跑关键词搜索和基于含义的搜索。关键词搜索能精准命中股票代码这类确切词;向量搜索能捕捉换一种说法的表达。
RRF —— 倒数排名融合
用每一项的位置而不是它的分数来合并两个排序列表。它从不读正文,而这恰恰就是它的局限。
Cross-encoder / 重排器
一个把问题和文档放在一起读、并给它们的匹配程度打分的模型。比比较向量准得多,也慢得多,慢到不可能跑遍整个集合——所以它只对一份候选短名单做重排。
recall@k / hit@k
在那些确实存在正确文档的问题里,有多大比例能让正确文档出现在前 k 个结果中。它衡量的是证据到底有没有拿到手。
Precision —— 精确率
返回的内容里,真正相关的占多少。召回率和精确率互相拉扯,这正是先放宽候选池、再做重排的原因。
上下文窗口(context window)
模型一次调用能读进去的最大文本量。这是一个硬天花板,而里面的每一点内容都要付出成本和延迟。
Coreference —— 指代
指向前文已经提过的东西的说法——「他们的分红」「那一个」。检索没法解析它,所以必须由流水线来做。
语义缓存(semantic cache)
当新问题表达的是同一个意思时,复用过去的答案;判断依据是向量相似度,而不是文本完全一致。
TTL —— 存活时间
一个存下来的条目在必须重算或丢弃之前,还能保持有效多久。
Checkpointer —— 检查点器
在每一步之后保存完整的工作状态,这样一次运行可以在重启后继续,也可以被精确重放。我的写进 PostgreSQL。
状态机 / 图
把流水线表达为带明确转移的命名步骤,而不是层层嵌套的函数调用。这让路由决策变得可检视、可测试。
Fan-out —— 扇出
把一个请求拆成并行的分支——在这里是每家公司一条检索分支——然后合并结果。
Hallucination —— 幻觉
一句流畅、自信却没有任何来源支撑的陈述。在金融领域,这是最要命的失败模式。
Groundedness —— 有据性
答案里的每一条陈述,是否都能在检索到的文档中找到。注意:有据的意思是「不是编的」,不是「是对的」。
LLM-as-judge —— LLM 评判
用一个模型给另一个模型的输出打分。可规模化,同时带有已知方向的偏差,所以评判器本身也需要拿人工标注来验证。
结构化输出(structured output)
强制模型返回固定形状——指定字段、指定允许值——而不是自由文本,好让下游代码可以依赖它。
Guardrail —— 护栏
一道限定系统可以接受什么、产出什么、说什么的检查。最好做成边界上的分层,而不是末端的一道过滤。
Fail open / fail closed
当检查本身出错时:fail open 放行请求,fail closed 拦下请求。正确的选择因层而异,而且应该白纸黑字写下来。
离线评估(offline eval)
一组固定的测试用例,带预期结果。可重复、也安全;但它只包含已经有人想到过的失败。
在线评估 / A/B 测试
在真实流量上测量,通常是把一部分请求发给新版本再做对比。它能发现真实的失败,代价是真实的用户风险。
Shadow mode —— 影子模式
新版本处理真实流量,但它的输出只记录、不展示。拿到的是真实分布,同时没有客户暴露。
灰度发布(canary release)
先发给一小部分流量,盯住指标,然后再扩大或回滚。
Golden set —— 黄金数据集
你用来卡发布的那套精挑细选、带标注的用例。它应该从真实流量里长出来,而不是只靠工程师想象。
Drift —— 漂移
行为随时间改变,而代码一行没动——新类型的问题、重新入库的文档、供应商悄悄更新了模型。
可观测性 / 链路追踪
能够看见一次请求内部发生了什么:每一步、每一条提示词、每一篇检索到的文档和每一个决策。LangSmith 是接进这个项目的追踪工具。
CI/CD —— 持续集成与持续交付
在每一次改动上自动跑检查、并把通过的部署出去的自动化。对 AI 系统来说,这个门禁必须是确定性的,否则它就会被忽略。
Prompt injection —— 提示注入
检索到的文档里那些试图充当模型指令的文本。它之所以要紧,是因为检索来的内容就紧挨着你自己的指令。
PII —— 个人可识别信息
能识别出某个人的数据。在银行里,它必须在进入日志、缓存或链路追踪之前被检测并脱敏。
n
一次测量背后的用例数量。把自己的 n 说出来,正是你展示自己清楚它能支撑什么、不能支撑什么的方式。