金融投关问答助手(IR-Agent)
IR-Agent 是投关场景的企业微信问答助手,服务两家上市公司(金域医学、归创通桥), 答的是年报、公告、历史问答里的财务与经营事实,带引用、可回查。 交付形态是客服直接能用的线上助手,不是能跑的 demo——这一条决定了后面所有取舍。
这个项目真正想清楚的是一件事:投关问答里最贵的不是答错,是「这道题根本不该让 Agent 干」。 一个 Agent 链路的成本、延迟和错误模式,在它启动之前就已经定好了。 所以系统第一道关口不是检索、不是提示词,是分流; 剩下的检索、工具、证据冻结、双核验,都是这道关口成立之后才谈得上的优化。
投关问答里最贵的不是答错,是根本不该答
投关类问答踩三个坑,每一个都能直接否定「一个大模型包打天下」:
- 问题类型混杂:纯文档检索(公告里的一段话)、结构化查询(财务指标算出来)、两者混合、以及该拒答的(要投资建议、问未公开信息)。一套 RAG 硬答,结构化问题必然答错,超范围问题必然编。
- 知识更新频率高:公告、财报、纪要不断出新。微调的知识会过期且不可追溯,只能走检索。
- 错了代价高:投关场景的答案会被直接引用出去,编造一个数字比答不上来严重得多。
第三条尤其要紧,它决定了整个系统的形态:既然答案会被引用,那「答了什么依据」就跟答案本身一样重要。 所以后面所有设计里都能看到同一个倾向——宁可保守地拒,也不肯没依据地答。
真实提问长这样:「金域医学 2024 年研发投入占营收比重是多少」「归创通桥有没有披露过集采中标情况」 「这个季度业绩预告和上次年报口径是不是一致」。前三句里,第二句是纯检索、第一句是算、第三句要做版本对照—— 同一批坐席手里的同一类问题,背后需要的是三种完全不同的机制, 这也是为什么「一个大模型包打天下」在这个场景里必然塌。
交付形态也是约束的一部分:入口在企业微信客服,坐席拿着手机回话, 所以答案必须能在会话里直接展开引用原文,而不是让人去另一个系统里查证; 答不上来时要有明确的拒答话术,坐席知道该不该转人工。
先拒掉一半:这个系统最大的优化发生在 Agent 启动之前
四路分流分别是拒答、闲聊类、单事实检索、多步 Agent。 判定走三层,第一层一定是规则硬预检(版本化 JSON 规则,专门处理局部否定、引述、转折的作用域), 第二层向量语义(1024 维 embedding),第三层小模型兜底。 路由输出只保留「路由 + 理由」两个字段,够评测回溯用,不给下游留解释负担。
设计依据很简单:分流错了的代价是整轮白跑。 宁可把难判的推给小模型多判一次,也不能让主模型先跑起来再发现走错门。 阈值来自网格搜索:相似度 ≥ 0.82、分差 ≥ 0.08 时整体准确 96.0%, 其中语义直达 56.8%(461 / 811),落小模型 43.2%(350 题),小模型层内正确 94.3%。
三层不是并列,是越往后越贵、越往后判得越准: 规则层几乎零成本,专门接住那些「不合规词、明显的投资建议请求、带局部否定的句式」—— 这一类不需要任何模型能力就能判死,交给模型反而引入波动; 向量层负责把语义上明显属于某一路的问题直接送到(占大头); 剩下的边界题才落到第三层小模型,而小模型在这一层里自己又要判对 94.3% 才托得住整体 96.0%。 门禁里那两条「误拒 ≤ 1%、漏拒 ≤ 2%」就是卡在这条链路上: 误拒太高,坐席会被正常的业务问题堵在门外;漏拒太高,超范围问题会漏进 Agent 被编出来。
856 题评测集上四路互斥分布是 拒答 80 / 闲聊 241 / 单事实 203 / Agent 332。 进 Agent 的 332 题里,189 题直接判为 Agent,143 题从单事实路升级上来, 最后真正走到最末一层的只有 18 题。换个说法:这批题里有 524 题全程没有启动 Agent。
189 / 143 / 18 这组数值得拆开看,它是整个路由设计的形状。 189 是一开始就认出来「这题得分步做」的;143 是检索拿到了证据、但只够回答一半问题, 于是被升级到 Agent 去补另一半;18 是前两层判完之后还悬着、必须由最末层收口的。 这条阶梯说明一件事:升级是有代价的,但不是一次性的开关—— 路由层允许「先按便宜的路走,发现不够再升级」,而不是要求它在第一跳就百分之百判对。 允许升级的前提是单事实路本身要能输出结果(哪怕是不完整的),否则升级就没了落点。
口径必须说清楚,否则这个数会被误读:启动率 = 332 / 856 = 38.79%, 相对「这批题全部走 Agent」的对照下降 524 / 856 = 61.21%。 这个分母是同一批题上「走与不走 Agent」的对照,不是线上新旧版本的 A/B。 也就是说收益几乎全部来自「这道题本来就不该进 Agent」——不是 Agent 变强了。
向量和词面各有一条命路,谁也替不了谁
过了分流,单事实路和 Agent 路共用同一套检索。双路各取 top_k = 30: 一路走向量库(HNSW + 内积,1024 维,以公司为分区键),一路走稀疏词面(BM25)。
词面这条路不能省。财务口径、单位、期间这类信号会被向量平滑掉—— 「万元」和「亿元」、「Q1」和「一季度」在语义空间里可能挨得很近, 但在投关问答里它们是两套完全不同的数。
两路分数不可比,硬凑到同一尺度必然有一路被压制,所以融合用 RRF,按排名取倒数相加, 常数 60 用来压低靠前排名的边际收益:
score(d) = 1 / (60 + rank_dense(d)) + 1 / (60 + rank_bm25(d))
融合结果先回关系库校验公司、证券代码、披露截止、口径,再过 cross-encoder 重排, 去重取 top 5(上限 10);重排失败会退化成纯 RRF,但仍然打降级标记,不假装成功。
重排放在后段是有道理的:cross-encoder 要逐对打分,成本跟候选数成正比, 放在 top 30 上跑还能接受,放在 top 300 上跑就不划算了; 而它放在 RRF 之后,又恰好能拿到两路融合后的候选集合,比单路重排看得更全。 上限 10 和取 5 也是一条边界:给模型太多块,它会把不相关的也当证据引用; 给太少,长周期经营事实又会被切碎。这个 5 是用评测集上 Context F1 的曲线定的,不是拍的。
最后一层是上下文清洗:按当前 query 把选中块拆成短句,保留块 ID 与句 ID, 把「检索到的块」变成「能定位到句」的证据。 存储上分工明确——关系库只存文档元数据与原文,向量库只存向量与可过滤元数据,对象存储存原件与解析产物,缓存层纯缓存。 口径是元数据的一部分,不是从答案里反推出来的。
把检索封成工具,而且入参里不写公司名
RAG 服务对外是双接口:MCP Server(供 Agent 以 MCP Client 方式调检索)+ HTTP 接口(供单事实路与监控), 两者共享同一份业务逻辑——检索策略改一次,全链路生效。
最关键的一处设计:检索工具的入参只暴露业务字段(query / 过滤条件 / 条数),不暴露公司、会话、时间基准, 这些经请求上下文注入,模型既看不见也伪造不了。 「这家公司是哪家的」由会话上下文决定,不该让模型在每次工具调用时重新声明一遍—— 一旦让它声明,少声明一次就是一次跨公司串数据的事故面。 这一条看着像洁癖,实际是这套系统能同时服务两家上市公司的前提。
Agent 侧只有三个只读工具:检索、查财务指标、算指标, 后两个是 14 个固定公式枚举加受限程序执行(Decimal 运算),不给模型自由发挥的表达空间。 另有一条坚持:链路里没有 Text2SQL——模型不写 SQL, 服务端用固定参数化查询并注入公司标识。在投关场景里,让模型拼 SQL 换来的只是注入面,换不来准确性。
双接口的划分也值得记一笔:MCP Server 给 Agent 用,HTTP 接口给单事实路和监控链路用。 单事实路不走 Agent 编排,直接调 HTTP;监控要在不打扰会话的前提下抽样重跑同一批题。 两条入口共享一份检索业务逻辑,所以「把检索策略改一次全链路生效」不是一句漂亮话—— Agent 路和单事实路拿到的是同一个 RAG 服务,不存在两份实现慢慢分叉的情况。
引用时刻在受理那一刻就冻住了
投关问答里最隐蔽的一类错误是「最新版和生效版不是一个版本」: 公司发了一份更正公告,模型答的是新口径,引用的文档却是旧的;或者反过来。 证据冻结不是一句提示词能解决的,是两条机制合起来的结果。
第一条是版本链:财务事实表带版本号和「被谁取代」指针。 发生更正时不删旧行、不覆盖旧值,插入新行并把旧行指向新行;point-in-time 取值固定为:
SELECT <字段> FROM financial_data
WHERE company_id = :cid AND published_at <= :reference_time
ORDER BY as_of_version DESC LIMIT 1
用户问的是「截至今天的口径」,就永远拿今天这个版本作答。
而 reference_time 在受理消息那一刻冻结,不是在生成答案时冻结——
一条消息跑十秒,中间正好来一份更正,答案就会自相矛盾。
文档状态走「暂存 → 生效 → 撤回」,缓存层只认已生效的文档。
撤回这条状态尤其要设计:公告被撤回不等于可以当它没发生过, 但已生效又被撤回的文档必须还能被引用到、并且带上撤回说明, 否则历史会话里那条已经发出去的答案就成了无源之水。 这也是 10 条断言里专门留一条「如果被取代过的版本必须带更正说明」的原因—— 它防的不是模型乱答,是证据链的时间线被悄悄剪断。
第二条是引用句柄:真实来源(某份公告的某一段)在渲染前映射成短句柄, 页面再据此逐条展开原文。句柄不跨请求存活,所以同一个结论在不同会话里引用编号可以不一样, 但每次展开回到的是同一份原文——编号是给人看的,原文是唯一的。
让代码判它该判的那部分
Agent 路有 10 条确定性断言:结构、任务覆盖、归属、公司、期间与口径一致、 单位与币种、展示数字等于计算函数的结果、证据是否足够、禁止引用别人的会话, 以及「如果被取代过的版本必须带更正说明」。 失败直接走安全失败,不做「让它再试一次」—— 修复类调用会引入新 token 和新不确定性,花在确定性断言上不划算。
语义侧由独立校验器按四维(完整性 / 忠实性 / 一致性 / 合规性)打分, 不调工具、不改答案,只产出问题清单;自修最多两次(即 Pass@3), 合规类严重违规直接拒答。单事实路只做零模型的轻量检查(数字有没有依据、单位口径有没有混用、合规词表)。
这层分工背后是一条硬原则:判定权不能放在模型手里。 工具名对不对、数字对不对、引用存在不存在,一律用代码判;模型只负责「想」。
自修只给两次也是有讲究的。Pass@1 到 Pass@3 这条曲线(244 → 294 → 312)说明大部分能修好的问题在第一次修复里就修好了; 再往后每多一次自修,新增的是噪声和成本,换回来的合格率增量已经很薄。 所以宁可让那 20 条过不了断言的题走到安全失败——让坐席转人工,也不要让它在一个坏状态上继续推演。 独立校验器不调工具、不改答案这一点同样重要:它一旦能改答案,就变成了第二个生成者, 评测时你就分不清错误是原模型犯的还是校验器改出来的。
数字该怎么读
- 启动率 38.79%(332 / 856),相对全量下降 61.21%(524 / 856)——上面那组分布的口径说明。
- Pass@1 / 2 / 3 = 244 / 294 / 312(分母 332),两次修复分别再拉回 50 和 18 题,最终 20 条未过;端到端成功 300 / 332。
- Context 类指标算的是「召回片段集合」与「标注金标准证据集合」这一对集合,不是答案级准确率。350 题、每题 4 条金标准,固定取 top5:金标准 1400 条、返回 1750 条、命中 1120 条。
P@5 = 1120 / 1750 = 0.64 R@5 = 1120 / 1400 = 0.80
F1 = 2 · P · R / (P + R) = 2 × 0.64 × 0.80 / 1.44 = 0.7111(71.11%)
- 计算位置在句子级清洗之后再做语义判定,不要求块 ID 稳定——同一段话换一种切法仍算命中;「资料不足」的题不计入分母。
- 「命中」的判定比看起来严格:召回片段与金标准证据只要有句级交集就算命中,但空召回也算命中为 0,「资料不足」这种主动拒答的题直接排除在分母外。所以 P@5 低不等于检索全废,也要看 R@5 有没有被拉住——两颗数分别盯的是「给多了」和「漏了」。
- 同一题集上 A 档(纯向量)→ B 档(+BM25 + RRF)Context F1 从 54.2% 到完整链路的 71.11%,MRR 0.714。增益拆解:词面 + RRF 41%、重排 32%、元数据过滤与上下文清洗 27%。
- 一处反直觉的对照:检索指标大涨 29.4pp,而答案忠实度只涨 6.9pp。检索变准了,答案能不能信只跟走一小截——「检索准」和「答案能信」之间隔着作答那一步,这正是双核验必须存在的原因。
- 评测分三层跑(检索题集、Agent 题集、路由回复类),程序性断言不交给 Judge:工具名、公司、财务数字、引用是否存在、是否超时、是否重复执行,一律用代码判。语义校验与人工盲评的一致性 Cohen's kappa = 0.81。
- 上线门禁:Context F1 ≥ 71.0%、路由准确 ≥ 95%、误拒 ≤ 1%、漏拒 ≤ 2%、引用绑定 ≥ 98%、跨租户 0、Agent 准确 ≥ 85%。线上错误自动分六桶归类,闭环是「定位 → 进评测集 → 灰度 10% → 50% → 全量 → 归档回归」。
- 评测集是分层构造的:检索题集盯 Context F1、Agent 题集盯 Pass@3 与端到端成功率、路由回复类盯四路分流的边界。三层各用自己的题集,避免一个指标涨上去把另外两层的退化盖住。
几件事想清楚之后
分流比调 prompt 有效得多。 八成的稳定性收益来自「这道题根本不该进 Agent」。一个 Agent 链路的收益上限,在它启动之前就定好了; 提示词调在后面,能把已经定死的盘子往上抬的余量很小。
判断题尽量前移。 规则能判的不交给模型,一次检索能解决的不进 Agent。判得越靠前,后面的每一步都越便宜—— 三层判定里最贵的是第三层小模型,但它只在 43.2% 的题上才被叫醒。
判定权不能放在模型手里。 工具名对不对、数字对不对、引用存在不存在,用代码判;模型只负责「想」。 把验收标准写成愿望,是最贵的一类 bug。
把检索封成工具,而不是散在代码里。 MCP 双接口共享同一份业务逻辑,检索策略改一次全链路生效,替换实现不用动编排—— 这一条在项目后续迭代里省下的时间,比任何一次调参都多。
证据要比答案活得久。 答案是一次性的,证据要能撑住三个月后有人翻出这条会话来问「你当时凭什么这么说」。 所以版本号、引用句柄、as-of 取值这三样东西,是给未来的追溯留的接口,不是给当期评测刷分用的。 最后一条是进线上之后才想明白的:评测集要跟着线上错误走,而不是固定不动。 线上错误自动分六桶归类,定位到的每一类都要回到评测集里变成分数—— 否则新版本上线时,你只是又跑了一遍老题,而老题上一个版本已经答对了。 「灰度 → 全量 → 归档回归」这条闭环真正的价值在这儿,不在灰度这一步。