作者建议尽量缩小 LLM 在系统中的职责,让其余逻辑保持确定性并接受常规单元测试。对无法消除的随机输出,则建立分层评测框架并明确每层检测能力。
每个上线 LLM 功能的团队,最终都会撞上同一堵墙:你构建的东西具有非确定性,而你整个测试文化的前提却是它具有确定性。当输出是一段生成的文字,并且下一次生成时会略有不同时,assertEqual(output, expected) 就毫无意义。
常见的两种应对方式都不理想。一种是耸耸肩,不做任何验证就直接上线:“我试过,看起来还不错。”另一种是测试模型本身,不断追逐一个移动的目标——每当 prompt 或权重发生变化,这个目标也会随之改变。
最近,我构建了一个内部工具,其中恰好有一个步骤依赖 LLM。我希望能真正回答“你怎么知道它有效?”这个问题,而且这个答案要经得起公开质疑。最终的答案包含两个动作。第一:尽可能缩小 LLM 的职责,让系统的大部分功能保持确定性,从而继续使用普通的单元测试。第二:对于剩下那部分无法消除的非确定性,构建一套真正的 eval 框架,并诚实面对其中每一层能够发现什么、又无法发现什么。
下面是整个过程。
在投资组合 onboarding 期间,上游平台会检测数据缺口:某栋建筑缺少一张水电账单、某个出现在公用事业数据流中的仪表无法匹配到任何已知空间、设备清单不完整,或者“这是租户支付还是业主支付?”存在歧义。随后,平台会生成一份异常报告。
接下来,内部团队中的某个人必须把检测到的每个缺口转化为一项具体请求,准确地发送给客户侧正确的负责人——例如,这栋建筑的问题找物业经理,那个仪表的问题找资产经理——并且在 30 天的 onboarding 时限结束前,持续跟进每个问题直至解决。整个过程就是:阅读报告、判断谁负责、起草邮件、跟踪谁已经回复,然后在几十栋建筑上不断重复。
这个工具接收异常报告,并针对每个缺口完成以下工作:确定路由信息(建筑、账户、负责人、严重程度)、创建一条可跟踪的记录、起草沟通内容,并在发送任何消息之前保留草稿,等待人工审批。专员只需审核并批准,而不必每次都从空白页面开始。
请注意,这里面有多少工作其实根本不是 LLM 问题。
路由是查表操作。严重程度直接来自上游信号。跟踪是一个状态机。审批是一个受保护的状态转换。这些事情都不存在歧义,也绝不应该委托给一个可能会自由发挥的模型。
因此,整个架构被清晰地分成两个部分:
确定性主干:路由、跟踪、状态转换,以及所有发送安全检查,都由普通代码实现。
LLM 辅助步骤:只负责一件事——起草沟通消息。这是唯一一个真正能从语言模型中受益的环节,因为它需要“把这个结构化的数据缺口转换成一段礼貌、具体,而且人类确实愿意发送的文字”。
路由就是对字典执行纯函数操作,周围完全没有任何模型 I/O:
ROLE_BY_GAP_TYPE = { GapType.MISSING_UTILITY_BILL: OwnerRole.PROPERTY_MANAGER, GapType.UNMATCHED_METER: OwnerRole.ASSET_MANAGER, GapType.INCOMPLETE_EQUIPMENT_INVENTORY: OwnerRole.BUILDING_ENGINEER, GapType.TENANT_OWNER_PAID_AMBIGUITY: OwnerRole.ASSET_MANAGER, }
严重程度很好地体现了这种约束原则。自由文本形式的详情字段经常会暗示事情的紧迫程度,因此很容易让人想把推断工作交给模型。但我没有这么做。严重程度根据明确的上游信号进行标准化;如果信号缺失或无效,这条异常就会直接报错,而不是获得一个猜测出来的值。规则很简单:相信确定性信号,绝不允许模型凭空制造信号。
LLM 被放在一个 provider 接口之后——它是一个只有一个方法的 protocol——因此业务逻辑永远不会直接接触 SDK:
class LLMProvider(Protocol): def complete(self, prompt: str) -> str: ...
这个接口既是一道边界,也是一个替换点。在本地,它直接调用 Claude;到了生产环境,只需修改一个工厂函数,它就会变成 Bedrock 调用。而在测试中,它会变成一个返回预设字符串的 fake。这意味着,整个确定性主干都可以在完全不需要 API key、也不发起任何网络请求的情况下得到验证。
这就是动作一带来的收益。因为 LLM 的职责范围很小,而且被严密隔离,所以系统的大部分依然只是普通软件。它拥有 28 个普通的单元测试:给定这条异常,路由必须生成完全一致的记录;无法路由的异常必须抛出错误;对状态不正确的数据缺口执行审批必须被拒绝。结果非通过即失败,由代码完成检查,并在每次 push 时通过 CI 运行。不需要任何花哨技巧。
如果把起草步骤变成确定性的,就违背了使用模型的初衷。因此,eval 框架被放在这里,通过一组人工标注的 golden dataset 进行评分,其中还包括刻意设计的对抗性样例。它检查三个维度。有意思的是,这三个维度并不具有同等的可信度,而我也不会假装它们同样可靠。
最重要的要求是:一份草稿只能引用它所针对的那一个数据缺口。如果草稿提到了另一栋建筑的 ID,或者完全不同的数据缺口,就属于严重失败:这意味着上下文发生了泄漏,这种内容绝不能以正确结果的名义交给人类。
这个问题可以通过确定性方法检查,因为泄漏的标识符具有固定结构。对草稿执行正则表达式匹配,找出其中所有 G-#### 或 BLD-### token;只要某个 token 不是当前数据缺口所允许的值,就判定为违规:
def check_groundedness(draft: str, gap: dict) -> GroundednessResult: violations = [] foreign_gap_ids = _foreign_ids(draft, gap["gap_id"]) if foreign_gap_ids: violations.append("references gap ID(s) not allowed: " + ", ".join(foreign_gap_ids)) foreign_building_ids = _foreign_ids(draft, gap["building_id"]) if foreign_building_ids: violations.append("references building ID(s) not allowed: " + ", ".join(foreign_building_ids)) return GroundednessResult(is_grounded=not violations, violations=violations)
需要坦诚说明的是,它无法发现以下问题:没有携带 ID 的自然语言级范围违规,例如只通过文字描述提到另一栋建筑;或者把源数据中的推测性备注重新表述成已确认的事实。这些问题需要语义判断。这条 guardrail 能发现的是结构化信息泄漏。我会准确描述它的能力,而不是声称它能捕获所有泄漏。
因为它是确定性的,所以同一个函数可以承担双重职责:既是实际请求路径中的实时 guardrail,也是 eval 框架中的一个评分维度。
这一层会标记草稿中缺乏源数据依据的数字和日期类 token,例如凭空编造的 kWh 数值、容量数字,或者超出指定时间窗口的日期。
我想非常明确地说明它的局限,因为在这里,人很容易产生自我欺骗。这只是字符串匹配,而不是语义检测。它可以发现编造出来的数字,却无法发现不含数字的虚假断言。典型例子是:某个数据缺口提出的问题是“这个仪表由租户支付还是业主支付?”,而草稿却悄悄替它给出了答案。这里没有任何数字可供标记;这个 hallucination 是一项断言,而不是一个数字。数据集中有几个陷阱正是这种形式,并且它们被刻意放在这项 eval 的能力范围之外。在这里通过检查,只能提供部分信号,而不是证明。
最后一个维度是真正主观的:这是否符合机构资产经理的表达风格?内容是否具体而不显得过分道歉?是否只提出了一个明确的请求?因此,这一维度由模型评分。每个样例都有自己的语气 rubric,其中一些描述的是应该避开的陷阱,而不只是表达风格。judge 会从具体程度、专业程度、单一且明确的请求,以及是否遵守 rubric 四个方面给出 1~5 分,及格阈值为 4 分。
LM judge 是工具箱里确定性最低的工具,正因如此,它只被限制在最主观的维度中,绝不会靠近任何发送安全检查。
整个框架的水平,取决于它依据什么进行评分。golden set 很小,只有 11 个手工标注的样例,但每个样例都带有 must_mention、forbidden_references、must_not_invent 和 tone_notes 标签,其中还有几个被设计成了陷阱。
租户支付还是业主支付的歧义案例,是我最喜欢的一个。正确的草稿应该明确提出这个问题,并请收件人进行澄清。错误的草稿则会擅自解决歧义,选择一个源数据从未支持过的答案。这是整个系统最需要正确处理的事情。请注意,它恰恰也是启发式 hallucination eval 无法发现的故障。它之所以被放进数据集和语气 rubric,是因为语义判断只能发生在那里。这个框架并不神奇,它只是一个用来编码那些你已经理解的陷阱的地方。
有一个设计选择让我很满意:当草稿在实际请求路径中无法通过 groundedness guardrail 时,草稿文本永远不会被持久化,也永远不会被返回。数据缺口记录仍然存在,因此人类可以看到路由成功、草稿生成失败,但未获得事实依据的文本本身不会被展示。背后的判断是,一份看似可信却内容错误的草稿,比完全没有草稿更加危险。失败就应该被明确展示为失败,而不是作为一条建议出现。
单元测试会在每次 push 时运行,eval 则不会。原因有两个:一是它们会发起真实的 API 调用,既有成本,也容易出现不稳定;二是语气维度由非确定性的 judge 评分,它可能会让流水线变红,而原因与代码回归毫无关系。因此,确定性主干由 CI 把关,eval 则作为本地步骤运行。每次运行的结果——包括完整的模型 I/O、延迟、token 使用量和分数——会被写入每次运行专属的可观测性目录。这样既能保留历史记录,又不必假装一个主观 judge 适合作为构建门禁。
这套框架只是一个起点,我宁愿如实说明,也不愿过度宣传。11 个样例按顺序运行,没有并行、没有重试,也没有 eval set 版本管理;这足以在开发 prompt 时发现回归,但还算不上一个持续维护的 eval set,更不应该装作成熟系统直接上线。hallucination 启发式检查需要逐步发展为对无依据断言的语义检查,而不只是检测无依据的数字。此外,在真实草稿经过真人审核,并根据他们的判断而不是我的直觉调整之前,语气维度的阈值也只是一种猜测。
如果你要把 LLM 放进一个出错会产生代价的系统中,对我有效的模式是:
缩小模型的职责,直到它只负责真正存在歧义的步骤;其他所有工作——路由、状态和安全检查——都保留在代码中,确保单元测试依然有效。
把模型放在接口之后,让它可以被替换;更重要的是,让它可以在测试中被 fake。
对唯一的非确定性步骤进行分层 eval,并且各层的可信度逐步降低:先进行确定性检查,再进行启发式检查,只有真正主观的部分才使用 LM judge。同时,要明确说明每一层无法发现什么。
把陷阱编码进数据集,因为真正的领域判断就存在于那里。
拒绝不合格的输出,而不是把它展示出来;同时,不要让主观 judge 成为构建门禁。
你无法对模型进行单元测试。但你可以构建一个大部分功能根本不需要模型的系统,并为确实需要模型的部分配备一套边界明确、坦诚面对自身局限的框架。
代码位于 GitHub:github.com/amirmarcel/partner-strategy-copilot。其中使用 Django REST Framework,将 Claude 放在 provider 接口之后;eval 框架位于 evaluations/,包含各种陷阱的 golden dataset 位于 golden_dataset/。
我是一名软件工程师,关注 AI 辅助系统的架构:确定性边界应该放在哪里,以及如何验证那些跨越边界的部分。欢迎反馈和质疑。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。