我目前主要的时间仍然在比赛,主要是没有什么其他的机会,比如说高质量的就业这类的,在 TAAC 和 LLM-Rec 两次比赛都没有进入复赛以后,这次我在思考是不是失败的本质是不是很缺算力,于是我选取了不怎么需要算力的比赛。最后方案在 AFAC2026 赛题四的 A 榜得到 96.9493,B 榜有效成绩为 93.4999,登顶b榜第一(1/1704)并在主办方复现下被邀请前往上海决赛答辩。


如果只看b榜结果,这似乎是一次比较顺利的进步。但实际过程还是比较曲折的。最早的提交只有 36.8684 分,中间有过明显上涨,也有过把已经超过 50 分的方案重新改回 30 多分的情况。相当长一段时间里,我一直在改 Prompt、增加 Agent 角色,却没有真正回答一个最基本的问题:一条答案到底是怎样从几百页文档中产生的?
这套方案最开始起于一个很朴素的想法:
如果检索足够准,模型是不是就会答对?
这个比赛一开始我就开始了思考,我在想agent是什么?信息是怎么传递的,赛题本质是不是只需要把信息送到准确的地方即可,以及如果检索足够准,模型是不是就会答对?比赛结束后再看,这句话只对了一半。模型当然需要读到正确的原文,但找到一段相关文本与凑齐回答问题所需的全部证据不是同一件事。即便所需数字都已经出现,主体、期间、单位、计算关系和最终选项映射仍然可能在后续环节出错。
最后真正有效的改动,不是让模型读更多,而是让信息流动得更受约束:
查询单 → 来源事实 → 确定性答案。
这篇复盘不会逐个列出内部版本,也不会把每次提交包装成有意义的实验,或者全篇工程处理这类的工作。我想回答四个问题,这也是我在比赛过程中一直在不断思考的:
- 为什么普通 RAG 和单次问答很快触顶?
- 意图究竟应该在哪一层产生?
- Planner、Reader 和 Controller 分别解决了什么问题?
- 为什么最后模型调用更少,答案反而更稳定?
赛题的难点在哪里
赛题包含财务报告、金融合同、保险条款、监管规则和证券研究报告五类材料。数据集共有 573 份文档,合计约 1.2 万页。题目既有选择题,也有需要输出数字、排序或组合结果的自由回答题。
长文本是最明显的困难,真正困难的是,这些题大多是确定性问题:少一个条件、看错一个年份、把百分比当成百分点,最终答案就会直接翻转。
以一道跨公司比较题为例,题目可能问 A 公司比 B 公司在某年的某项收益率高多少。文档里未必直接写出两家公司的收益率,而是分别给出收入和成本;有时某一年只给了累计值,还需要用相邻期间做差;两家公司的数据又可能位于不同报告、不同页和不同表格中。
要回答它,系统至少需要闭合下面这些变量:
- A 公司、目标期间、收入、成本及来源;
- B 公司、目标期间、收入、成本及来源;
- 两边一致的指标口径和单位;
- 收益率公式、差值方向和最终精度。
传统 Top-k 检索优化的是“哪几段最像问题”。但比较题需要的是“每一个必要变量有没有到齐”。A 公司的多个高相似段落可能占满前几名,导致 B 公司的唯一必要证据根本没有进入上下文。此时检索结果看起来很相关,答案却从一开始就不可能正确。
这也是我后来对赛题最重要的重新定义:
金融长文本问答不是从全文中挑出一段最像答案的话,而是在有限 Token 下闭合一组带来源的证据变量。
最初的方案为什么只有 36.8684
第一版方案很普通,非常的朴素:PDF 转文本、切块、关键词和 BM25 检索、拼接 Top-k 上下文,再让模型一次性输出答案。
它并非完全无效。对“某条款规定了多少天”“报告期末某项数值是多少”这类单点事实题,只要关键词足够明显,模型经常可以直接答对。但一旦题目出现跨页、跨公司、选项逐项核验或计算关系,整个流程就开始不稳定。
当时我首先怀疑的是 Prompt。于是加入更长的任务说明、更多约束、更复杂的输出格式,也尝试把一次调用拆成 Planner、Reader 和 Judge。成绩从 36.8684 上升到 50.6604,再到 55.2313,看起来方向是对的,但很快再次停住。
后来才意识到,这种写法只是把一个大 Prompt 拆成三个角色名:
Planner:分析问题
Reader:阅读材料
Judge:判断答案
三个模型仍然在处理几乎相同的自然语言上下文,中间传递的还是自由文本。Planner 说“请查找相关财务数据”,Reader 说“根据材料似乎是……”,Judge 再依据这段二手描述做一次判断。角色增加了,但没有新增可验证的中间状态,也没有减少任何不确定性。
这一阶段还有一个明显错误:我把“禁止模型做错什么”写得太多,而没有把“模型要交付什么”定义清楚。长串否定式约束并不会自动形成稳定能力。真正有用的是正向任务、明确字段、输入输出契约,以及能够覆盖复杂情况的示例,从chatgpt 5.2往后的时期里,我个人认为长段提示词的作用是越来越偏向负面的,openai本身的使用示例以及anthropic的使用建议也是如此提示的,在语义中我个人认为只表述什么不是什么也是毫无意义的,我们需要告诉模型我们究竟需要什么。
虽然Prompt 可以改善表达,却无法补回根本没检索到的证据,提示词本身是不创造信息的;第二个模型也无法判断第一段自由文本是否抄错了数字。架构不是模型角色的数量,架构是每一层接收什么、产出什么,以及下一层能否检查上一层。
第一个低垂果实其实是文档工程
最开始我也倾向于把注意力放在模型和 Prompt 上,因为它们最容易看到变化。真正拉开差距的第一步却是文档处理。
金融 PDF 并不是干净的连续文本。常见问题包括:
- 表格被按阅读顺序打散,表头和数值失去对应关系;
- 扫描页没有文本层,或者 OCR 把小数点、负号和百分号识别错;
- 页眉页脚反复进入每个文本块;
- 同一张表跨页,后一页没有重复完整表头;
- 研报图表中的关键数字只存在于视觉区域;
- HTML、TXT 和 PDF 的章节结构完全不同。
最终预处理同时使用 PyMuPDF、PaddleOCR 和 pdfplumber 做交叉抽取。PyMuPDF 提供稳定的页级文本,OCR 补齐扫描页,pdfplumber 用于恢复财报和研报中的版面与表格。清洗以后并不是只生成一个巨大的 TXT,而是保留页、块、标题、实体、期间、指标、数值、单位和原文之间的映射。
最后形成的数据大致包括:
| 数据层 | 规模 | 用途 |
|---|---|---|
| 原始文档 | 573 份 | 保留官方来源与文档边界 |
| 页级文本块 | 23,318 个 | 提供可寻址的原文窗口 |
| 结构化数值事实 | 52,360 条 | 快速定位主体、期间、指标与数值 |
| 领域原子 | 93,145 条 | 保存条款条件、合同关系和监管规则 |
| 研报视觉/表格事实 | 10,175 条 | 补充图表邻接文本和视觉数值 |
这些数据被写入 SQLite 文档地图,并建立 FTS5/BM25、字符 trigram、标题别名和字段索引。正式问答阶段没有使用 embedding、向量数据库或 dense retrieval。这里并不是认为向量检索没有价值,而是当前任务中大量查询包含明确的公司名、年份、指标、条款和数值关系,结构字段与字符级召回更容易审计,也更容易定点回到原文。
文档地图解决的不是“替模型总结全文”,而是让后续系统能够回答:某个主体、某个期间、某项指标可能在哪一份文档、哪一页、哪一个块。

意图不是在第一句 Prompt 里一次性猜出来的
做到文档地图以后,我重新思考了另一个问题:意图到底从哪里产生?
常见做法是在用户原始输入层先让模型做一次意图识别,再按照模型给出的关键词检索。但模型在这一刻还没有文档内部结构。它可以看懂“A 公司比 B 公司高多少”,却不知道文档是否直接给出了指标,是否需要从原始变量计算,也不知道两个主体分别出现在哪里。
如果题目足够直接,那么关键词、实体识别和字段匹配本来就可以完成大部分路由;如果题目不直接,一次脱离文档结构的自由推理也很难凭空规划出正确坐标。
最后我的答案是:
意图产生于完整问题约束与文档结构的交点,并随着坐标、事实和缺口被发现而逐步具体化。
在系统里,它不是一段不可检查的“思考过程”,而是一组不断收窄的状态:
完整问题与选项
→ 主体 / 期间 / 指标 / 关系
→ 必要证据槽
→ 文档 / 页 / 块坐标
→ 来源事实与变量
→ 未闭合缺口
→ 最终答案
完整题目和全部选项作为不变的任务头,会再次提供给 Reader,以及真正需要触发的 Judge/Fallback。中间各层不重复传递越来越长的自然语言总结,而只传递当前阶段新增的结构化状态。这样既不会让第一步理解偏差变成不可逆错误,也不会在每个文本块前机械重复题目、浪费上下文。
Planner:把问题变成查询单
最终方案里的 Planner 并不是一次大模型调用,而是确定性的查询规划程序。这里的“Planner”描述的是系统职责,不代表它必须由 LLM 扮演,所以它的模型 Token 为 0。
它接收:
- 完整问题与全部选项;
- 文档目录和紧凑文档地图;
- 已召回的候选块、结构化事实和字段索引;
- 当前已经闭合和仍然缺失的证据槽。
它先解析主体、期间、指标、比较关系、计算要求和输出格式,再按答案变量拆出独立查询。例如三家公司同一指标的比较题,不是生成一个“大致查资产负债率”的查询,而是建立三个彼此独立的槽位:
S1 = 公司A × 2025年 × 资产负债率
S2 = 公司B × 2025年 × 资产负债率
S3 = 公司C × 2025年 × 资产负债率
CALC = 对 S1/S2/S3 使用同一公式,排序并求极差
每个槽位分别运行字符 n-gram、FTS5/BM25、主体别名、年份与指标匹配;必要时再进行精确全文扫描。输出不只是关键词,而是一张查询单:去哪里读、找什么变量、这些变量最后怎样组合,以及当前还缺什么。
这一步的价值不是“让程序比模型更聪明”,而是把模型最不稳定的全局搜索变成可枚举、可覆盖检查的任务。Planner 可能仍会漏召回,但漏的是一个明确槽位,系统可以继续补它;自由文本规划漏掉的信息通常连缺口都不会留下。
Reader:把局部原文变成事实账单
Reader 才是正式流程中主要使用 Qwen 的环节。模型为 qwen3.7-plus,它接收完整问题、全部选项、Planner 查询单、来源目录以及坐标附近的局部原文。
Reader 不直接垄断最终答案。它要交付的是事实账:
facts:某个选项或证据槽被支持、反驳,还是尚未解决;variables:主体、期间、原始值、单位和来源坐标;calculations:由哪些原始变量组成、应该执行什么表达式;answer_parts:当前证据下可以形成的候选结果;gaps / risk_flags:缺失来源、口径冲突或疑似 OCR 问题。

例如原文给出“2025 年末资产负债率 70.74%”,Reader 不能只返回“权益乘数约 3.42”。它需要保留原始比例、期间、主体、所在页和计算式。后面的任何结果都应该能沿着这些字段回到原文。
这种压缩与普通摘要不同。普通摘要追求语言简洁,可能把页码、单位、例外条件和互相冲突的说法一起压掉;事实账只删除当前问题不需要的叙述,来源、变量、条件、关系和缺口仍然保留。
如果 Reader 发现某个槽位没有闭合,程序最多追加一轮定点取证,只加入新增证据,不把整篇文档重新发送一遍。这才是我最后理解的“动态记忆”:不是不断重写一篇更短的全文摘要,而是围绕问题维护一个可以增加、冲突、核验和闭合的事实状态。
Controller:模型理解,程序闭合
Reader 已经抽出了正确数字,并不代表答案一定正确。早期方案里出现过很多这种错误:
- 原始比例正确,模型在四则运算时算错;
- 题目问“不正确的是”,最后仍按正向选项输出;
- 数值单位分别是元、万元和亿元,模型直接比较;
- 题目要求百分点差,模型输出百分比变化率;
- 推理文本已经判断 A、B、C,但 CSV 映射成了另一组答案。
这些都不是需要更强语义能力的问题,实际上我们不能指望模型把所有的事情一次性做好,就目前而言模型并不是一个万能许愿机,截止到我完成这篇文章的2026年9月14日,仍然会有SOTA模型在计算9.11-9.9时输出的结果等于0.2。继续加一个 Judge,本质上只是让另一次随机生成检查第一次随机生成。最终方案把它们交给确定性 Controller。
Controller 会检查来源、主体、期间、单位和公式;使用 Decimal 和受限表达式执行计算;处理比例尺度、单位换算、最高最低集合、累计项去重和最终舍入;选择题则根据各选项事实状态与题干正负方向完成映射。

如果来源变量、可执行公式与模型写下的字面结果冲突,Controller 使用来源绑定的原始变量重新计算。只有证据仍不完整或语义关系无法由规则闭合时,才进入补证、Judge 或 Fallback。
正式复现的 100 道题中,93 题由确定性 Controller 完成最终闭合,7 题进入 Fallback 路径,只有 1 题实际调用了模型 Judge。这里最有价值的不是“又少调用了一个模型”,而是系统终于知道什么时候已经有足够证据停止,以及什么时候确实还缺东西。
相关性不等于证据完整性
为了验证按槽位检索是否真的比增加 Top-k 更有效,我从 43 道题中整理了 155 个必要证据槽。这个实验不调用模型,Token 表示预计交给 Reader 的文本量;证据坐标来自机器复核,因此它衡量的是检索覆盖,不是官方答案准确率。
| 检索方案 | 槽位召回率 | 全槽完整题比例 | 中位文本 Token |
|---|---|---|---|
| Top-k 4 | 60.65% | 27.91% | 2,338 |
| Top-k 8 | 75.48% | 44.19% | 4,494 |
| Top-k 16 | 88.39% | 67.44% | 8,710 |
| Top-k 32 | 92.90% | 76.74% | 17,699 |
| Evidence-slot k4 | 89.68% | 72.09% | 5,308 |

Top-k 8 与 Evidence-slot k4 的全槽完整题比例从 44.19% 提高到 72.09%,配对 McNemar 检验 p=0.0118。继续把 Top-k 增加到 32 的确可以获得 76.74%,但中位文本预算达到 17,699 Token;按槽位检索以约 30% 的文本预算接近了这一覆盖水平。
这个实验给了我一个比“召回更多”更具体的目标:每个答案变量至少要有自己的证据通道。最相关的四段可能都在解释 A,公司 B 的一段低相似文本却可能是不可缺少的答案组成部分。
从 36.8684 到 96.9493
回头看 A 榜的方案演进,每次真正的提升都对应一个被明确识别的失败环节:
| 阶段 | A 榜成绩 | 主要变化 |
|---|---|---|
| 静态检索 + 单轮问答 | 36.8684 | Top-k 文本直接交给模型 |
| 检索增强 + Prompt 组装 | 50.6604 | 改善查询、上下文和输出约束 |
| Planner-Reader-Judge 初版 | 55.2313 | 拆分角色,但中间状态仍偏自由文本 |
| 文档地图 + 定点取证 | 82.2875 | 从段落相关性转向页块坐标与证据覆盖 |
| 事实账 + Controller | 96.0868 | 模型抽取事实,程序校验和复算 |
| 最终 A 榜 | 96.9493 | 补齐领域规则、来源和边界处理 |

这条曲线最容易被误读成“只要继续堆工程规则就会稳定涨分”。实际并非如此。中间有不少改动让结果明显回退,尤其是把更多上下文重新塞给模型、让多个角色重复判断,以及在没有证据契约时增加补充检索。真正能够累计的改动都有一个共同点:它们留下了可观测状态,能说明错误发生在召回、抽取、计算还是格式化。
A、B 榜数据和评分环境不同,两者不能直接横向比较。最终 B 榜的有效成绩为 93.4999,进入前 6。这里的价值并不是证明系统已经解决了所有金融长文本问答,而是说明这套信息流在隐藏数据上仍然保持了较高稳定性。
Token 不是最后才看的装饰指标
官方 Baseline 的准确率为 13%,消耗 3,884,045 Token。正式复现使用 qwen3.7-plus 完成 100 道题,共产生 118 次 API 调用:
| 项目 | 数量 |
|---|---|
| 输入 Token | 433,855 |
| 输出 Token | 82,110 |
| 总 Token | 515,965 |
| 平均每题 | 约 5,160 |
| 完整运行时间 | 约 28 分钟 |
相对 Baseline,总 Token 减少约 86.72%。按比赛期间阿里云百炼的限时价格,以冷缓存口径估算,完整复现约 1.22 元。人民币只是帮助理解量级,审计时仍以服务端返回的 Token usage 为准。
我还做过一组固定 20 题的 Token 诊断。由于没有独立公开标准答案,这组实验不用于声称答案准确率,只观察不同读取策略的成本:
| 方法 | 总 Token |
|---|---|
| 普通检索 + 单次问答 | 80,455 |
| 压缩记忆 + 单次问答 | 66,964 |
| 精确全扫描 + 单次问答 | 119,617 |
| 动态记忆 + 单次问答 | 120,464 |
| 完整 Agent | 89,182 |
“完整 Agent”并不是表中 Token 最少的方案,但它低于把大量动态记忆一次性交给模型的做法。更重要的是,它把 Token 花在了尚未闭合的槽位上,而不是平均分配给所有文档和所有题。
长上下文模型让“塞进去”变得可行,却没有让无关上下文免费。更多文本会增加调用成本,也会增加主体串线、单位混淆和中间位置遗忘的机会。Token 预算不是答完题以后再压缩的指标,它应该从检索目标和控制流开始参与设计。
与其他长文本路线的关系
后来重新看 ReadAgent、LongAgent、Chain-of-Agents 以及 MapReduce-style 全分块阅读等工作,我不认为这套方案发明了一个全新的长文本范式。已有研究早就在探索摘要记忆、多 Agent 分块阅读、跨块通信和选择性激活。
我们的选择更像是针对金融确定性问答,把问题推向了证据工程的一端:
- 当答案依赖少量、可定位、可结构化闭合的事实时,先选择再阅读很有效;
- 当相关性只有在广泛阅读以后才会出现,或者大量段落共同决定结论时,全分块与迭代共享记忆更合适;
- 当任务目标是开放式总结、观点综合或创作时,严格事实槽可能反而限制表达。
因此,这套方法的优势和上限来自同一个假设:必要证据可以被文档地图定位。地图漏掉关键证据,Reader 就没有机会纠正。复杂表格单元格对齐、低质量 OCR、视觉图表关系和跨领域规则迁移,仍然是最明显的工程边界。
Embedding 和 rerank 也不是与这套方法冲突。它们完全可以作为候选生成器加入文档地图,尤其适合词面差异很大的查询。关键不在于使用哪一种召回算法,而在于召回以后是否仍按主体、期间和答案变量检查证据完整性,是否能回到确定来源。
关于我的线下答辩
很遗憾,虽然我以b榜单第一进入决赛,但是最终并未获奖。实际上b榜单和复现成绩只占70%,答辩占30%。而对赛题4竞争太激烈了,总共1704只队伍参加比赛,前排所有人的分数都很紧,最终答辩分数才是最重要的。
可能是我胆怯了以及答辩表现不太好,或者说双非本科生有一些debuff,总之我在决赛9进6的答辩中未获得奖项。我本来以为一个好的答辩应该是既有技术深度又能让不懂技术的人也能听的懂一些东西,为此我准备了很多例子,而且尽可能的把方案讲的足够朴素。
其实我还是比较需要这笔奖金的,打比赛确实耗钱。我本来都计划好了拿着奖金去更换设备了,但是很遗憾没有得奖,不过对我对我来说也足以让我进步了,在下一次比赛答辩中我会更加从容以及明白一份好的答辩是怎么样的。我仍然相信我能在下一次竞赛中再度进入决赛。