很多团队第一次把 AI Agent 接进内部系统时,最先做的是两件事:接知识库,接工具。知识库负责回答制度、流程、产品说明,工具负责查订单、查余额、查审批状态。Demo 看起来很顺,用户问一句,Agent 检索几段材料,再调用一两个接口,最后给一个完整答案。
真正上线后,问题往往不是"没有答案"。更麻烦的是"证据太多,而且互相打架"。
制度正文说差旅餐补以城市级标准为准;一份旧 FAQ 写着统一 80 元;财务接口返回北京 100 元、苏州 60 元;用户历史报销单里又出现过 90 元。普通 RAG 会把最像问题的 FAQ 排在前面,普通 Agent 会把接口结果和文档摘几句拼起来,最后用非常确定的语气告诉用户"餐补上限 80 元"。
这类错误很隐蔽。HTTP 状态码是 200,工具调用成功,检索也有命中,日志里看不到异常。用户发现报销被驳回,才知道 Agent 答错了。
我现在更愿意把这类能力叫做"证据仲裁",而不是简单的"检索增强"或"幻觉检测"。检索只负责把可能相关的材料找出来,仲裁要回答另一个问题:当多份材料都看似相关时,哪一份应该被信任,哪一份应该被降权,哪一种冲突必须触发拒答或追问。
这篇文章不做工具清单,也不讲泛泛的 RAG 搭建。我们用一个可复现的小案例,把生产 Agent 里证据仲裁器该怎么设计讲清楚。
一、相关不等于可信,命中不等于支持
很多线上事故都来自一个很朴素的误会:相似度高的证据就是好证据。
向量检索的目标是找语义接近的片段,rerank 的目标是把更符合 query 的片段排前面。它们解决的是"像不像",不是"该不该信"。旧 FAQ 里如果刚好写了"餐补上限是多少",它当然和用户问题很像;制度正文可能写成"补贴标准由财务系统按城市等级动态生效",反而没那么像用户的自然语言问题。
于是系统会出现一个很荒诞的现象:越像用户问题的旧材料,越容易盖过真正权威但措辞不同的新材料。
公开的 Agent 可观测性资料里反复提到一个点:传统 APM 能告诉你一次请求是否成功、耗时多少、调用了哪些服务,但很难告诉你 Agent 为什么选了这条路径。RAG 观测资料也强调,检索质量、上下文缺失、材料过期、生成是否忠于材料,这些都不是 HTTP 成功率能覆盖的指标。
证据仲裁器要补上的就是这一层。它不替代检索,也不替代生成模型。它夹在检索和生成之间,把"候选材料"变成"可被引用的证据决策"。
二、一个最小故障样本
先构造一个小数据集。假设我们有一个内部报销政策 Agent,它能读取三类数据。
| 来源 | 内容摘要 | 更新时间 | 权威等级 |
|---|---|---|---|
| company_policy_2026 | 差旅餐补以财务系统中城市级标准为准,制度页面只说明计算原则 | 2026-06-01 | 0.95 |
| hr_faq_2024 | 国内出差餐补统一为每天 80 元 | 2024-03-10 | 0.45 |
| finance_api | 北京每天 100 元,苏州每天 60 元,三线城市每天 50 元 | 实时 | 1.00 |
| reimbursement_case | 某员工去年在杭州报销过每天 90 元 | 2025-09-12 | 0.30 |
用户问:
我下周去苏州出差,餐补一天最多能报多少?
一个普通 top-k 流程可能拿到 FAQ 和历史报销单,因为"餐补""一天""报多少"这些词都很接近。财务接口虽然最权威,但如果它作为工具结果在后一步出现,模型很可能已经被前面的 80 元锚定。最后答案就会摇摆,甚至直接错。
我们希望正确答案是:
按当前财务系统,苏州出差餐补上限为每天 60 元。旧 FAQ 中的 80 元是过期统一标准,不能作为最终依据;历史报销单不是制度来源,只能用于排查异常,不能用于当前政策判断。
这句话背后有四个动作。
- 把用户问题拆成 claim:地点是苏州、费用类型是餐补、需要当前上限。
- 判断每条证据对每个 claim 是支持、反驳还是无关。
- 结合来源权威性、更新时间、工具实时性,做冲突仲裁。
- 如果冲突无法解决,禁止模型硬答,转为追问或人工升级。
这就是证据仲裁器的核心。
三、Evidence Envelope:别把裸文本扔给模型
很多系统的问题从数据结构就埋下了。检索阶段返回一组字符串,然后直接拼进 prompt:
text
Context:
1. 国内出差餐补统一为每天 80 元。
2. 苏州每天 60 元。
3. 差旅餐补以财务系统中城市级标准为准。
这对模型很不公平。它看到的是三个句子,不知道哪个来自制度、哪个来自旧 FAQ、哪个是实时接口,也不知道更新时间、权限边界和适用范围。
正确做法是给每条证据包一层 envelope。
ts
type EvidenceSourceType = 'policy' | 'faq' | 'api' | 'case' | 'user_input';
type EvidenceEnvelope = {
id: string;
sourceType: EvidenceSourceType;
sourceName: string;
text: string;
updatedAt: string;
authority: number; // 0~1,制度/实时系统通常更高
freshness: number; // 0~1,按业务半衰期计算
accessScope: 'public' | 'internal' | 'restricted';
retrievedBy: 'vector' | 'keyword' | 'tool' | 'manual';
retrievalScore?: number;
metadata?: Record<string, string>;
};
这里最重要的是 authority 和 freshness。它们不应该交给模型临场判断,而应该由业务系统显式给出。
比如制度库可以规定:
ts
const SOURCE_AUTHORITY: Record<EvidenceSourceType, number> = {
api: 1.0,
policy: 0.95,
faq: 0.45,
case: 0.30,
user_input: 0.20,
};
这不是为了做一个完美评分系统,而是为了把组织内早就存在的信任关系写进 runtime。财务接口本来就比员工历史报销单权威,正式制度本来就比 FAQ 权威。Agent 不应该重新发明这套规则。
freshness 也一样。不同材料的过期速度不同。价格、库存、政策额度可能按天变化;产品手册可能按月变化;法律条款可能按版本变化。可以先用一个简单半衰期函数:
ts
function freshness(updatedAt: string, halfLifeDays: number, now = new Date()): number {
const ageMs = now.getTime() - new Date(updatedAt).getTime();
const ageDays = Math.max(0, ageMs / 86400000);
return Math.pow(0.5, ageDays / halfLifeDays);
}
政策 FAQ 的半衰期可以设成 90 天,实时接口直接设成 1。这样旧 FAQ 即使语义很像,也不会在仲裁时天然占优。
四、Claim-Support Matrix:先拆主张,再看证据
下一步不要急着生成答案。先把用户问题和候选答案拆成 claim。
在报销案例里,至少有三个 claim:
ts
type Claim = {
id: string;
text: string;
kind: 'fact' | 'policy' | 'calculation' | 'recommendation';
risk: 'low' | 'medium' | 'high';
};
const claims: Claim[] = [
{ id: 'c1', text: '用户出差地点是苏州', kind: 'fact', risk: 'low' },
{ id: 'c2', text: '餐补上限需要按当前政策判断', kind: 'policy', risk: 'high' },
{ id: 'c3', text: '苏州当前餐补上限为每天 60 元', kind: 'calculation', risk: 'high' },
];
然后让一个轻量判定器或规则模块判断每条证据和每个 claim 的关系。
ts
type SupportLabel = 'support' | 'contradict' | 'irrelevant' | 'partial';
type ClaimSupport = {
claimId: string;
evidenceId: string;
label: SupportLabel;
confidence: number;
quote: string;
};
矩阵可能长这样:
| claim | company_policy_2026 | hr_faq_2024 | finance_api | reimbursement_case |
|---|---|---|---|---|
| c1 苏州 | 无关 | 无关 | 支持 | 无关 |
| c2 按当前政策 | 支持 | 部分支持但过期 | 支持 | 无关 |
| c3 上限 60 元 | 间接支持 | 反驳 | 支持 | 反驳或无关 |
这个矩阵的价值很大。它把"文档看起来相关"变成了"这条证据具体支持哪个主张"。生成模型最后只能使用被矩阵确认支撑的 claim,而不是把所有 retrieved chunk 都当成事实来源。
如果你担心 模型辅助评审 成本,可以先做混合方案。结构化字段、金额、日期、城市名用规则判断;语义支撑关系再交给小模型或主模型批处理。大多数业务场景里,最危险的 claim 往往就是金额、日期、权限、状态这几类,规则反而更可靠。
五、仲裁器:把冲突变成可执行决策
有了 evidence envelope 和 support matrix,就可以写仲裁器了。
ts
type ArbitrationDecision = {
claimId: string;
verdict: 'accepted' | 'rejected' | 'needs_clarification' | 'escalate';
answerable: boolean;
winningEvidenceIds: string[];
rejectedEvidenceIds: string[];
conflictType?: 'none' | 'soft' | 'hard';
score: number;
rationale: string;
};
function supportScore(e: EvidenceEnvelope, s: ClaimSupport): number {
const labelWeight =
s.label === 'support' ? 1 :
s.label === 'partial' ? 0.45 :
s.label === 'contradict' ? -1 : 0;
return labelWeight * s.confidence * (
0.50 * e.authority +
0.30 * e.freshness +
0.20 * (e.retrievalScore ?? 0.5)
);
}
function arbitrateClaim(
claim: Claim,
evidence: EvidenceEnvelope[],
supports: ClaimSupport[]
): ArbitrationDecision {
const rows = supports.filter(s => s.claimId === claim.id);
const byId = new Map(evidence.map(e => [e.id, e]));
const scored = rows.map(s => {
const e = byId.get(s.evidenceId)!;
return { support: s, evidence: e, score: supportScore(e, s) };
});
const positive = scored.filter(x => x.score > 0).sort((a, b) => b.score - a.score);
const negative = scored.filter(x => x.score < 0).sort((a, b) => a.score - b.score);
const bestPositive = positive[0];
const strongestNegative = negative[0];
if (!bestPositive) {
return {
claimId: claim.id,
verdict: claim.risk === 'high' ? 'needs_clarification' : 'rejected',
answerable: false,
winningEvidenceIds: [],
rejectedEvidenceIds: negative.map(x => x.evidence.id),
conflictType: negative.length ? 'hard' : 'none',
score: 0,
rationale: '没有足够证据支撑该主张',
};
}
const hasHardConflict = strongestNegative && Math.abs(strongestNegative.score) > bestPositive.score * 0.8;
if (hasHardConflict && claim.risk === 'high') {
const positiveAuthority = bestPositive.evidence.authority;
const negativeAuthority = strongestNegative.evidence.authority;
if (positiveAuthority - negativeAuthority < 0.25) {
return {
claimId: claim.id,
verdict: 'escalate',
answerable: false,
winningEvidenceIds: [bestPositive.evidence.id],
rejectedEvidenceIds: [strongestNegative.evidence.id],
conflictType: 'hard',
score: bestPositive.score,
rationale: '高风险主张存在强冲突,且权威差不足以自动仲裁',
};
}
}
return {
claimId: claim.id,
verdict: 'accepted',
answerable: true,
winningEvidenceIds: positive.slice(0, 3).map(x => x.evidence.id),
rejectedEvidenceIds: negative.map(x => x.evidence.id),
conflictType: negative.length ? 'soft' : 'none',
score: bestPositive.score,
rationale: negative.length
? '存在冲突证据,但胜出证据在权威性和时效性上明显更强'
: '主张被可信证据支撑,未发现有效反证',
};
}
这段代码故意没有写得很"智能"。生产系统里,第一版仲裁器越朴素越好,因为它必须可解释、可调参、可回放。你要能回答用户和业务方:为什么 Agent 没信 FAQ?为什么这次没有直接回答?为什么某个工具结果压过了文档?
模型可以参与判断"这句话是否支持那个 claim",但最终是否采纳,最好由一个确定性仲裁层决定。
六、四个接入点:不要只在生成后查幻觉
很多团队把"幻觉检测"放在最终输出之后。答案生成完,再跑一个检查器,看是否忠于上下文。这个步骤有用,但太晚了。
证据仲裁至少有四个接入点。
| 接入点 | 目标 | 失败时动作 |
|---|---|---|
| 检索后 | 过滤过期、低权威、权限不匹配材料 | 降权或移除 |
| 工具结果后 | 合并实时接口与文档证据 | 重新仲裁相关 claim |
| 生成前 | 只把 accepted claim 和证据交给模型 | 不可回答时追问或升级 |
| 输出前 | 检查最终答案是否引入未支撑 claim | 阻断、重写或人工确认 |
关键是生成前这一刀。不要把所有冲突材料都塞进 prompt,然后指望模型自己"综合判断"。模型很擅长写出流畅解释,但不应该承担组织权威规则的最终裁判。
生成 prompt 可以变成这样:
text
你只能基于 accepted_claims 回答。
不得使用 rejected_evidence 中的信息作为结论依据。
如果用户问题涉及 blocked_claims,必须说明当前证据冲突,建议查询人工或最新系统。
accepted_claims:
- c1: 用户问的是苏州出差餐补
- c2: 餐补上限以财务系统城市级标准为准
- c3: 苏州当前餐补上限为每天 60 元
rejected_evidence:
- hr_faq_2024: 旧 FAQ,统一 80 元标准已过期
- reimbursement_case: 历史个案,不代表当前制度
这比"下面是检索内容,请回答"安全得多。
七、把证据决策写进 Trace
没有可观测性,仲裁器上线后很快会变成另一个黑盒。用户投诉"Agent 答错了",你只看到最终答案和几个 chunk,仍然不知道当时为什么选了 A 没选 B。
我建议每个高风险回答都记录下面这些字段:
ts
type EvidenceTrace = {
traceId: string;
userQuery: string;
claims: Claim[];
evidence: Array<{
id: string;
sourceType: EvidenceSourceType;
sourceName: string;
authority: number;
freshness: number;
retrievalScore?: number;
}>;
supports: ClaimSupport[];
decisions: ArbitrationDecision[];
finalAnswerHash: string;
blocked: boolean;
};
如果你们已经接了 开放遥测,可以把关键字段打到 span attribute 或 event 里:
ts
span.setAttribute('agent.evidence.count', evidence.length);
span.setAttribute('agent.claim.count', claims.length);
span.setAttribute('agent.evidence.blocked', decisions.some(d => !d.answerable));
for (const d of decisions) {
span.addEvent('evidence.arbitration', {
'claim.id': d.claimId,
'claim.verdict': d.verdict,
'claim.conflict_type': d.conflictType ?? 'none',
'claim.score': d.score,
'evidence.winners': d.winningEvidenceIds.join(','),
'evidence.rejected': d.rejectedEvidenceIds.join(','),
});
}
注意不要把敏感原文全量写进日志,尤其是合同、财务、客户资料。存 evidence id、hash、来源、分数、裁决理由就够排障了。需要复盘时,再通过权限系统回查原文。
这也是很多 Agent 可观测性实践里的核心思想:trace 不只是"记录发生了什么",还要能把生产样本转成测试集。一次证据仲裁失败,应该沉淀成一条 regression case,而不是在群里吵完就过去。
八、一个小实验:top-k 为什么会输给仲裁器
我们用前面的四条材料做一个最小实验。假设用户问题是"苏州出差餐补一天最多能报多少"。
普通 top-k 的排序可能是:
- hr_faq_2024:国内出差餐补统一为每天 80 元
- reimbursement_case:杭州历史报销每天 90 元
- finance_api:苏州每天 60 元
- company_policy_2026:以财务系统城市级标准为准
如果直接生成,模型很容易回答 80 元,或者写成"通常 80 元,但可能按城市调整"。这对用户没有帮助,因为用户要的是可执行结论。
加入仲裁器后,排序变成决策:
| 证据 | 相似度 | 权威 | 时效 | 对 c3 的关系 | 仲裁结果 |
|---|---|---|---|---|---|
| hr_faq_2024 | 0.91 | 0.45 | 0.08 | 反驳 | rejected |
| reimbursement_case | 0.78 | 0.30 | 0.45 | 无关/反驳 | rejected |
| finance_api | 0.72 | 1.00 | 1.00 | 支持 | accepted |
| company_policy_2026 | 0.66 | 0.95 | 0.85 | 间接支持 | accepted |
这个实验不证明某个固定公式永远正确,它证明的是一件工程事实:相似度只能作为证据候选的入口信号,不能作为最终信任信号。信任必须引入业务权威、时效、冲突关系和风险等级。
九、Eval 数据集怎么建
证据仲裁器最怕"看起来合理,线上失效"。所以第一版上线前,我会做一个很小但尖锐的数据集,不追求数量,追求冲突质量。
建议从生产或准生产里挑 20 类样本:
- 新旧政策冲突。
- FAQ 与正式制度冲突。
- 历史个案与当前规则冲突。
- 用户输入与系统记录冲突。
- 两个工具返回不同状态。
- 权限不足导致证据缺失。
- 检索命中相似但业务对象不同。
- 同名产品不同版本。
- 金额、日期、地区这类结构化字段冲突。
- 文档说"以系统为准",系统结果又为空。
每条样本至少标注三个东西:期望答案、必须采纳的证据、必须拒绝的证据。
json
{
"query": "我下周去苏州出差,餐补一天最多能报多少?",
"must_accept": ["finance_api", "company_policy_2026"],
"must_reject": ["hr_faq_2024", "reimbursement_case"],
"expected_claims": [
"苏州当前餐补上限为每天 60 元",
"旧 FAQ 不作为当前依据"
],
"should_block": false
}
评估指标也不要只看最终答案相似度。至少要看四个:
| 指标 | 含义 |
|---|---|
| accepted evidence precision | 被采纳证据里有多少确实该采纳 |
| rejected evidence recall | 应拒绝证据有多少被系统拒绝 |
| unsupported claim count | 最终答案里有多少 claim 没有证据支撑 |
| necessary block recall | 该拒答或升级的样本里,系统拦住了多少 |
这四个指标比"回答打分 4.3/5"更适合工程迭代。因为它们能定位问题发生在检索、支撑判断、仲裁阈值还是生成约束。
十、常见坑
第一,把 rerank 分数当可信度。 rerank 只能说明材料和问题贴近,不说明材料权威。旧文档、论坛回答、历史个案都可能非常贴近。
第二,让模型自己决定哪份证据可信。 模型可以解释规则,但组织里的权威关系必须外置。正式制度、实时系统、用户口述、历史记录,这些信任等级应该由业务配置决定。
第三,只保存最终答案。 只存答案无法复盘。至少要保存 claim、evidence id、support label、仲裁结果和拒绝理由。
第四,所有冲突都继续回答。 高风险 claim 一旦出现强冲突,应该追问或升级。比如金额、权限、合规、医疗、法律、合同条款,不要为了"体验流畅"硬答。
第五,证据粒度太粗。 一整篇文档命中并不代表整篇都支持结论。最好把文档切到条款级、段落级,最终引用也落到 claim 级。
第六,没有权限边界。 有些证据对系统可见,但对当前用户不可见。仲裁器不能把 restricted 证据变相泄露进答案。它可以说"当前权限下无法确认",不能说出受限内容。
十一、小团队一周落地版本
如果你没有时间做完整平台,可以按这个顺序上第一版。
第一天,改数据结构。所有检索和工具结果都包成 EvidenceEnvelope,至少补齐 sourceType、updatedAt、authority、retrievedBy。
第二天,挑 20 条冲突样本。不要挑简单问答,要挑新旧政策、FAQ 与系统冲突、历史个案误导这类样本。
第三天,做 claim 拆分。先只覆盖高风险字段:金额、日期、地区、状态、权限。能规则拆就规则拆,别一开始就追求通用。
第四天,实现仲裁器。公式简单一点没关系,但要能输出 accepted、rejected、needs_clarification、escalate。
第五天,改生成 prompt。只允许模型使用 accepted claim。blocked claim 必须追问或升级。
第六天,把仲裁结果写进 trace。注意脱敏,记录 id 和 hash,不要把敏感原文全塞日志。
第七天,跑 eval,调阈值。重点看 unsupported claim 和 necessary block,而不是只看答案流畅度。
一周后你不会得到一个完美的"事实系统",但会得到一个很关键的能力:当 Agent 不确定时,它知道自己为什么不确定;当证据冲突时,它不会因为某段文本更像问题就自信撒谎。
结语
AI Agent 的生产可靠性,不只取决于模型多强、检索多准、工具接得多全。真正困难的是把这些不稳定信号合成一个可解释、可回放、可阻断的决策。
证据仲裁器听起来像一个中间层小模块,但它改变了系统默认行为:从"找到相似材料就回答",变成"只有被可信证据支撑的 claim 才能回答"。
这就是很多 Agent 从 Demo 走向生产时缺的那一层刹车。
如果你现在的系统已经有 RAG、有工具调用、有 trace,但用户仍然偶尔反馈"它说得很像真的,但就是错了",不要急着换模型。先检查一件事:你的 Agent 到底是在检索证据,还是在仲裁证据。