简介:这份资源是一份人工智能主题的读书报告文档,面向计算机、自动化、电子信息等专业的学生,以及希望系统梳理人工智能学科脉络的自学者与考研复习者,可用于课程作业参考、读书笔记整理与知识框架搭建。文档围绕三条主线展开:一是从亚里士多德的形式逻辑、培根的归纳法、莱布尼茨的数理逻辑,到图灵机与图灵试验,梳理人工智能的思想源流;二是介绍逻辑法与搜索法两大基本方法,以及归结原理、语义网络、专家系统、反向传播算法等关键节点;三是归纳人工智能在符号计算、模式识别、机器翻译、数据挖掘等方向的应用,并谈及我国专家系统与类人机器人的研究进展。压缩包内为单个 docx 文件,体积约 19KB,篇幅紧凑、结构清晰,按发展史、发展方向、应用领域分章组织,便于快速通读或摘取要点。该资源已有 308 人学习,适合作为入门阶段建立整体认知的轻量读物。
1. 一本 30 万字的书丢进对话框,为什么大多只回你一份正确的废话
把一本 30 万字的书丢进对话框,让它出一份人工智能读书报告,十次里有九次你会拿到一篇挑不出错、也记不住一句话的东西:结构工整、语气得体、观点全是"本书深刻揭示了……"。问题不在模型笨,而在于你把"读完整本书"和"写一份可核对的报告"这两件完全不同的事,压进了同一次调用里。
人工智能读书报告真正难的环节不在写作,而在前面三步:把书拆成可定位的章节树,让每条结论都带着页码出处,最后只做归并与取舍。这份活更像数据管线,不像写作。下面按我实际搭过的那条路子讲:解析怎么选、章节树怎么重建、切块参数怎么定、成本怎么算,以及五个我翻过车的具体位置。
2. 拆书:把一本 PDF 变成带章节树的文本块
报告质量的上限在解析阶段就被定死了。很多人一上来就调提示词,最后发现报告里 80% 的错——页码错位、章节名张冠李戴、把图注当作者观点——根子都在解析。一本书进模型之前,它在你眼里不该是一坨字符串,而应该是一条(chapter_path, page_no, text)的三元组流,顺序稳定、页码可追、层级清楚。
2.1 解析选型:为什么先做版面还原再谈摘要
三条常见路径拿到的产物完全不同,选错了后面全是补丁。
| 路径 | 常见工具 | 拿到什么 | 页码可信度 | 适用场景 |
|---|---|---|---|---|
| 纯文本抽取 | pypdf 一类 | 连续字符串,无坐标 | 低,跨页断行和双栏串行普遍 | 单栏纯文字、不需要标出处 |
| 版面分析 | pdfplumber、PyMuPDF | 每个字符的坐标、字号、字体 | 高 | 有目录、有图表、报告要标页码 |
| OCR + 版面分析 | OCR 引擎 | 文字框 + 置信度 | 中,需人工抽检 | 影印版、扫描件、老书 |
只要报告里要出现"第 213 页"这种字样,就必须走版面分析。原因很实在:页码不是模型生成的,是你从坐标里带出来的。纯文本抽取把页面拍平之后,"这句话在哪一页"这个信息永久丢失,后面无论怎么问模型都只能瞎猜——这就是最典型的黑匣子,你连排查都无从下手。
另外两个细节决定后面的返工量。第一,双栏排版必须按 x 坐标先分栏再排序,否则左右栏会交替串行,读起来像乱码,模型会把它当成一段莫名其妙的论证。第二,图表说明(caption)要单独打标保留,别混进正文段落。图注常常是全书最凝练的结论句,模型特别爱把它当作作者的正式主张引出来,但图注往往带"数据来源:作者整理"这种尾巴,混进正文就成了硬伤。
解析产物我一般要求满足三个硬指标:每行带 y 坐标与字号、每个文本块带页码、每个块能挂到一个章节路径上。前两个靠解析库,第三个靠下一节的重建。
2.2 章节树重建:用字号聚类和页码偏置对齐目录
最省事的做法是直接读 PDF 自带的书签(outline)。但真实书里大概一半没有书签,另一半书签是排版软件自动生成的"第 1 章 / 1.1 / 1.1.1"这种没有语义的层级。所以我一般两条腿走:有书签用书签校验,没书签就自己从版面里重建。
重建的核心观察是:正文里标题行的字号一定大于正文,而且同一级标题的字号高度一致。把全书出现频次高于 5 次、且字号排在前 15% 的行抽出来,按字号降序聚成 2~3 个簇,大致就对应章、节、小节三级。再配合目录页给出的标题文本做字符串匹配,能把层级钉死。
这里有个坑,第一次做的人几乎都踩:目录页写的页码是"书内页码",和 PDF 的物理页序差一个偏置。前言、版权页、序常用罗马数字单独编号,导致正文第 1 页在 PDF 里可能是第 13 页。偏置不能用固定值,要在正文里找三五个目录标题,算出各自的物理页 - 目录页,取众数。
import re from collections import Counter import pdfplumber HEADER_RATIO = 0.6 # 同一行文本跨页出现比例超过它,判为页眉页脚 LINE_TOL = 3 # 同一行的 y 坐标容差,单位 pt SIZE_TOL = 0.5 # 字号聚类容差,单位 pt def page_lines(page): """把一页的字符按 y 坐标聚成行,同时保留该行的主字号。""" words = page.extract_words(extra_attrs=["size"]) rows = {} for w in words: rows.setdefault(round(w["top"] / LINE_TOL), []).append(w) lines = [] for key in sorted(rows): ws = sorted(rows[key], key=lambda x: x["x0"]) text = "".join(x["text"] for x in ws) # 中文不按空格拼接 size = Counter(round(x["size"] / SIZE_TOL) for x in ws).most_common(1)[0][0] lines.append({"y": key * LINE_TOL, "text": text, "size": size * SIZE_TOL}) return lines def drop_running_heads(doc): """统计每行文本的跨页出现频率,页面上下边缘的高频行判为页眉页脚。""" freq = Counter() for page in doc.pages: for ln in page_lines(page): if ln["y"] < page.height * 0.08 or ln["y"] > page.height * 0.92: freq[re.sub(r"\d+", "#", ln["text"])] += 1 # 页码归一化后再统计 n = max(len(doc.pages), 1) return {t for t, c in freq.items() if c / n >= HEADER_RATIO} def build_outline(doc, toc_entries, banned): """toc_entries 来自目录页解析,形如 [(title, printed_page)];返回带物理页的章节树。""" offsets = [] for title, printed in toc_entries: for i, page in enumerate(doc.pages, start=1): hit = any(title[:8] in ln["text"] for ln in page_lines(page) if ln["text"] not in banned) if hit: offsets.append(i - printed) break offset = Counter(offsets).most_common(1)[0][0] if offsets else 0 return [{"title": t, "page": p + offset} for t, p in toc_entries]这段代码做成三件事。page_lines把字符聚成行,LINE_TOL=3是因为 PDF 里同一行的字符 top 值常有 1~2pt 抖动,容差设太小会把一行拆成两行,设太大又会把相邻两行粘一起;中文正文行距一般在 12~20pt,3pt 是个安全值。drop_running_heads用"跨页出现频率"识别页眉页脚,HEADER_RATIO=0.6的意思是全书 60% 以上的页面都出现同一行文本,那基本就是书名或章名跑到了页眉;把数字归一化成#是为了让"第 12 页"和"第 13 页"算作同一行。build_outline里的 offset 取众数而不是均值,因为只要有一本书目录标题和正文标题措辞不完全一致(比如正文多了个副标题),匹配就会偏,众数能把这种离群值挡掉。
提示:跑完这一步先别急着往下走,抽 3 个章节打印"标题 + 物理页 + 该页前 50 字",肉眼核对一遍。这一步花两分钟,能省掉后面两小时的重跑。
2.3 切块参数:800 字/块与 15% 重叠怎么定
切块大小由三个约束夹出来:单次调用的上下文余量、摘要的粒度、单个块内论点的完整性。我的默认值是每块 800 汉字、重叠 15%(约 120 字),长句多、术语密的学术书降到 600 字,叙述性强的传记类可以放到 1200 字。
为什么不切更大?块越大,模型越容易在块内做过度归纳,把三段不同的论证揉成一句正确的废话,你连它漏了哪段都看不出来。为什么不切更小?块小到只剩两三句时,代词指代丢失,"这一观点"到底是哪个观点,模型只能编。15% 的重叠是为了防止一个完整论证被切在边界上,代价是约 15% 的重复 token 消耗——这个代价我认为值得付。
| 参数 | 默认值 | 调整信号 | 调过头会怎样 |
|---|---|---|---|
| 块大小 | 800 汉字 | 单块摘要遗漏明显时调小 | 太大:块内过度归纳,漏论点 |
| 重叠比例 | 15% | 边界处出现语义断裂时调大 | 太大:重复 token,成本线性上升 |
| 单块上限 | 1800 token | 频繁触发截断时下调 | 超过上限会被静默截断 |
切的时候按章节边界优先,章节内部再滑窗。一章不足 800 字就整章一块,别硬拆——一章一块能让章节摘要天然对齐,后面归并省事很多。
def split_by_headings(sections, max_chars=800, overlap=0.15): """sections: [{'path': '第3章/3.2', 'text': '...', 'page': 87}]""" blocks = [] for sec in sections: text = sec["text"].strip() if len(text) <= max_chars: blocks.append({**sec, "part": 0}) continue step = int(max_chars * (1 - overlap)) # 滑动步长 start, part = 0, 0 while start < len(text): piece = text[start:start + max_chars] blocks.append({"path": sec["path"], "page": sec["page"], "part": part, "text": piece, "head": part == 0}) # 首块继承章节标题 if start + max_chars >= len(text): break start += step part += 1 return blocksstep是滑动步长,max_chars × (1 - overlap)= 680,相邻两块重叠 120 字。head标记首块,用来在提示词里带上章节路径;非首块只带"第 3 章 3.2 的第 2 段"这种弱定位,避免模型把每个块都当成一个新章节重新开头。
切片完成后,每个块要带四个字段:path、page、part、text。page是块的起始页,块跨页时按字符比例估算——这一点要提前想清楚,因为报告里的页码引用全靠它,估算误差一般在 ±1 页,够用。如果接受不了这个误差,就在解析阶段把字符级的页码一起存下来,切片时按偏移量映射,代价是索引文件大概翻三倍。
3. 报告骨架:先定结构再喂模型,别让它自由发挥
自由发挥是报告废掉的第二大成因。你给一句"写一份读书报告",模型会给你一篇有开头有结尾有升华的散文,看着像样,但你没法比较两本书的报告,也没法批量生产。正确做法是先定死骨架,把"写什么"从模型手里拿走,只留"怎么写"给它。
3.1 六个固定字段:把报告结构写成 JSON Schema
我用的骨架是六段:书目信息、一句话主旨、章节地图、核心论点与证据、关键概念表、立场与局限。这六段的设计原则是可核对——每一段都必须能指向原文的某个位置,凡是不能指向原文的段落,就是模型在自由发挥。
书目信息给书名、作者、版次、页数这类硬字段,全部从解析结果里取,不允许模型生成。一句话主旨限 60 字以内,逼模型做取舍。章节地图是一张表,每章一行:章节名、页码范围、该章主张一句话。核心论点与证据是主体,每条论点后面必须跟一到两条证据,证据格式固定为"原文摘句(第 N 页)"。关键概念表处理术语,原文里出现的术语给中文译名和书内定义。立场与局限是唯一允许模型判断的段落,但要它标明哪些是书里的、哪些是自己的推断。
用 JSON Schema 约束比在提示词里写"请输出 JSON"可靠得多。前者是硬约束,后者是劝告。
{ "type": "object", "required": ["meta", "thesis", "chapter_map", "arguments", "concepts", "limits"], "properties": { "meta": { "type": "object", "required": ["title", "author", "pages"], "properties": { "title": {"type": "string"}, "author": {"type": "string"}, "edition": {"type": "string"}, "pages": {"type": "integer"} } }, "thesis": {"type": "string", "maxLength": 60}, "chapter_map": { "type": "array", "minItems": 3, "items": {"type": "object", "required": ["name", "page_range", "claim"], "properties": {"name": {"type": "string"}, "page_range": {"type": "string"}, "claim": {"type": "string", "maxLength": 50}}} }, "arguments": { "type": "array", "minItems": 3, "maxItems": 12, "items": {"type": "object", "required": ["point", "evidences"], "properties": { "point": {"type": "string"}, "evidences": {"type": "array", "minItems": 1, "items": {"type": "object", "required": ["quote", "page"], "properties": {"quote": {"type": "string", "maxLength": 120}, "page": {"type": "integer"}}}}}} }, "concepts": {"type": "array", "items": {"type": "object", "required": ["term", "definition"], "properties": {"term": {"type": "string"}, "definition": {"type": "string", "maxLength": 80}}}}, "limits": {"type": "string"} } }几个约束是刻意的。thesis限 60 字、claim限 50 字,是因为长度一放开,模型立刻滑向"本书系统论述了……的重要意义"这种能套在任何书上的句子。arguments限 3 到 12 条,上限逼它排序而不是罗列,下限防止它偷懒只写两条。evidences里quote限 120 字、必须带整数page,这是整份报告的锚点:没有页码的论点在渲染阶段会被直接丢掉。
注意:Schema 约束的是形状,不是正确性。字段齐全、格式合规、内容全是编的报告一样能通过校验。页码的真实性得靠下一节的抽取式流程来保证。
3.2 抽取式先行:先定位原句,再让模型改写
生成式流程最容易出的问题是"引用看起来对,书里根本没有"。我现在的做法是两段式:第一遍只做抽取,让模型从块里挑出关键句并原样返回,同时给出页码;第二遍才做归纳,让模型在给定的句子集合里做聚类和改写。第一遍的输出是原文的副本,第二遍的输出才是文字。
这样做的直接好处是幻觉被压在第一道关口。第一遍模型拿到的是一块 800 字的原文,任务只有"挑出 2~4 句最能代表本块主张的话,原样复制",这个任务的幻觉空间很小,即使挑错了,错的也是选句而不是编句。第二遍模型看到的是带页码的句子列表,它的任务是合并同类项和压缩,不接触原文,也就没机会篡改。
第二遍的提示词里我一般写三条硬规则:不得引入列表之外的信息;同一论点合并时保留页码最小的那条证据;两条证据页码相差超过 40 页且主张不同时,拆成两条论点分别写。最后一条是防"跨章缝合",模型特别爱把第 2 章的观点和第 9 章的观点合成一句看起来很深刻的判断,实际上作者前后是改过立场的。
代价是调用次数翻倍。一本 30 万字的书按 800 字一块,约 375 块,抽取一遍 375 次调用,归纳一遍按章节归并约 30 次,再全书归并一次。总调用量 400 出头。这个量级用并发放到 6 路,跑完大概十几分钟到半小时,取决于你手上的接口速度。我评估过把前两遍合成一遍的做法——省一半调用,但引用准确率会掉到七成左右,不划算。
3.3 长上下文直投与 Map-Reduce 的成本账
很多人第一反应是"现在上下文窗口这么大,整本书塞进去不就行了"。30 万字中文按 1.5 字/token 粗算约 20 万 token,确实塞得下。但要算三笔账。
第一笔是钱。整本直投的输入 token 是块级切分的 1 倍(切分有 15% 重叠,实际是 1.15 倍左右),看起来更省。但输出质量要求高时你会重跑,重跑一次就是 20 万 token;块级切分有缓存,改一段提示词只影响未命中的块,实际重跑成本可能只有原来的 1/10。
第二笔是延迟与失败面。单次 20 万 token 的调用,首字延迟经常到几十秒,中途失败就全废;块级切分失败只丢一块,重试成本几乎为零。
第三笔是可控性,也是最关键的一笔。整本直投时,模型对全书的注意力分布是不均匀的,靠后的章节容易被压缩,你没法定位是哪一段出的问题。块级切分下,每一块都有独立的中间产物,哪块摘要写歪了,改那一块重跑就行。
| 维度 | 整本直投 | 块级 Map-Reduce |
|---|---|---|
| 输入 token(30 万字) | 约 20 万,一次付清 | 约 23 万(含重叠),可缓存 |
| 调用次数 | 1 | 400 上下 |
| 单次失败影响 | 全量重跑 | 单块重跑 |
| 定位问题的能力 | 弱 | 强,可逐块对照原文 |
| 章节对齐 | 靠模型自己分 | 天然对齐 |
| 适合的书 | 10 万字以内、结构松散 | 长篇、需要标页码 |
我的实际选择是混合:10 万字以内的书直接整本投,配一次结构校验;超过 15 万字一律走块级。分界线不是拍脑袋,是试出来的——12 万字上下时两种方案的报告质量肉眼已经分不出差别,那就选便宜的那个。
4. 全流程跑通:一条能断点续跑的最小管线
前面三章讲的是设计,这一章是可以直接跑的东西。整条管线的目标很朴素:给一个 PDF 路径,输出一份带页码引用的 Markdown 报告,中途断掉能接着跑。
4.1 目录结构与依赖
整条管线我拆成五个文件,每个文件职责单一,出问题好定位。
| 文件 | 职责 | 输入 | 输出 |
|---|---|---|---|
parse.py | 解析 PDF、去页眉、建章节树、切块 | 书的 PDF | blocks.jsonl |
extract.py | 块级抽取关键句 | blocks.jsonl | quotes.jsonl |
merge.py | 章节归并 + 全书归并 | quotes.jsonl | report.json |
render.py | 渲染 Markdown、回填页码 | report.json | report.md |
check.py | 断言回查、质量打分 | report.md+ 原文 | 分数与问题清单 |
依赖装起来不多,流派上尽量选纯 Python 的,省得在不同机器上编译。
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install pdfplumber jinja2 tenacity tqdmpdfplumber负责版面,jinja2负责渲染模板,tenacity负责重试退避,tqdm只为了看进度条。模型调用层自己写一个薄封装,不绑定任何具体厂商,换接口只改一个函数。
4.2 调度层:并发、重试与断点缓存
这一层做的事看着无聊,但它决定了你一天能跑几本书。三个必做项:并发控到 6 路以内、失败按指数退避重试、每块结果落盘做缓存。
import hashlib, json, os, threading, time from concurrent.futures import ThreadPoolExecutor, as_completed CACHE_DIR = ".cache" PROMPT_VERSION = "v3" # 改动提示词必须同步改这里 MODEL_NAME = "your-model" # 换模型也要改,否则会读到旧缓存 MAX_WORKERS = 6 _lock = threading.Lock() def block_key(block): """缓存键 = 块内容 + 提示词版本 + 模型名,三者任一变化都必须重算。""" raw = f"{PROMPT_VERSION}|{MODEL_NAME}|{block['text']}" return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] def cached_call(block, prompt_builder, call_model): key = block_key(block) path = os.path.join(CACHE_DIR, key + ".json") if os.path.exists(path): with open(path, encoding="utf-8") as f: return json.load(f) prompt = prompt_builder(block) result = call_model(prompt) # 内部用 tenacity 做 4 次指数退避重试 tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) os.replace(tmp, path) # 原子写,防止并发写坏文件 return result def run_all(blocks, prompt_builder, call_model): out = [] with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: futures = {pool.submit(cached_call, b, prompt_builder, call_model): b for b in blocks} for fut in as_completed(futures): out.append(fut.result()) return outblock_key里拼进PROMPT_VERSION和MODEL_NAME是血泪经验:只按文本内容做键,改完提示词之后你会发现结果一点变化都没有,查半天才想起来缓存命中——这是我最想要的一颗后悔药。MAX_WORKERS=6不是拍脑袋,多数接口在 8 路以上开始出现限流和超时,6 路是个稳定点,本地小模型视显存降到 1~2 路。
os.replace做原子写入,是因为并发场景下直接写目标文件有概率写出半截 JSON,下次读到就崩。用临时文件 + 重命名,写入要么完整要么不存在。缓存按块存的好处是重跑时只补未命中的部分,一本书改一个字段,实际调用量可能只有个位数。
4.3 两次归并:章节级先合,全书级再合
归并不能一次性做完。375 个块的关键句直接喂给模型让它写全书报告,等于又回到"一次调用压缩一切"的老路。我的做法是两次归并,第一次按章节合并,第二次按全书合并。
def merge_chapter(chapter_path, quotes, call_model): """章节级归并:只输入本章的抽取句,输出本章论点列表。""" payload = [{"quote": q["quote"], "page": q["page"]} for q in quotes if q["path"] == chapter_path] prompt = ( "下面是同一章节的原文摘句,每句带页码。\n" "任务:合并为不超过 5 条论点,每条论点附 1~2 条证据。\n" "规则:不得引入列表之外的信息;证据页码必须来自输入;\n" "两条证据页码相差超过 40 页且主张不同时,拆成两条。\n\n" + json.dumps(payload, ensure_ascii=False) ) return call_model(prompt, schema="chapter_arguments") def merge_book(chapter_results, meta, call_model): """全书级归并:只输入章节论点,不接触原文。""" prompt = ( "下面是一本书各章节的论点列表(已带页码)。\n" "任务:输出全书主旨(60 字内)、核心论点 3~12 条、关键概念表。\n" "规则:跨章合并时必须确认主张一致;冲突的主张在 limits 里点明。\n\n" + json.dumps(chapter_results, ensure_ascii=False) + "\n书目信息(不得改写):" + json.dumps(meta, ensure_ascii=False) ) return call_model(prompt, schema="book_report")关键在于第二次归并的输入是"章节论点"而不是原文。这样做的好处有两个:输入量从 23 万 token 压到几千,成本可以忽略;模型的注意力集中在论点之间的冲突上,而不是埋在原文细节里。
merge_chapter里那句"两条证据页码相差超过 40 页且主张不同时拆开"是硬规则,不是建议。写作者在书里改立场是常事,序言里说 A,第九章结论是 B,模型把它们合成一条就成了"作者既 A 又 B",读起来像在说废话,实际上是缝合出来的。
merge_book里把meta单独传进去并注明"不得改写",是因为书名作者这类字段一旦让模型经手,它就会顺手把副标题补全、把译者写成作者,而且补得特别像真的。
4.4 渲染:Markdown 模板与页码回填
渲染阶段要做两件事:套模板,以及把没有页码的论点丢掉。第二步比第一步重要。
import re from jinja2 import Template TEMPLATE = Template("""# {{ meta.title }} 读书报告 - 作者:{{ meta.author }} 版次:{{ meta.edition }} 页数:{{ meta.pages }} - 一句话主旨:{{ thesis }} ## 章节地图 | 章节 | 页码 | 该章主张 | | --- | --- | --- | {% for c in chapter_map -%} | {{ c.name }} | {{ c.page_range }} | {{ c.claim }} | {% endfor %} ## 核心论点与证据 {% for a in arguments -%} ### {{ loop.index }}. {{ a.point }} {% for e in a.evidences -%} > {{ e.quote }}(第 {{ e.page }} 页) {% endfor %} {% endfor %} ## 关键概念 | 术语 | 书内定义 | | --- | --- | {% for c in concepts -%} | {{ c.term }} | {{ c.definition }} | {% endfor %} ## 立场与局限 {{ limits }} """) def render(report, page_max): """渲染前做一次硬过滤:页码越界或缺失的论点直接剔除。""" kept, dropped = [], 0 for a in report["arguments"]: evs = [e for e in a["evidences"] if isinstance(e.get("page"), int) and 1 <= e["page"] <= page_max] if evs: kept.append({**a, "evidences": evs}) else: dropped += 1 report["arguments"] = kept md = TEMPLATE.render(**report) return re.sub(r"\n{3,}", "\n\n", md), dropped # 压掉多余空行page_max是全书物理页数,用来做越界检查。这个检查拦掉的量比想象中大:第一版跑出来,375 个块里有十几个块的页码超出了总页数,原因是偏置算错。dropped计数要打出来,正常情况下应该在 5% 以内,超过 10% 说明抽取阶段出了问题,得回头看extract.py。
re.sub(r"\n{3,}", "\n\n", md)是排版修正。模板里{% for %}加-控制空白之后仍会留空行,压一下输出更干净。这一步纯属体面,但对最终成品的观感影响不小。
5. 排查清单:AI 读书报告最容易翻车的五个位置
跑过十几本书之后,我把所有问题归了归类,发现 90% 的返工集中在五个位置。下面每一条都按"现象、原因、解决"写,你照着对号入座就行。
5.1 页码全部整体偏移
现象:报告里所有页码都比实际位置少 13 页或者多 3 页,而且偏差一致,不是随机错。
原因:目录页页码与物理页序的偏置算错了。最常见的两种触发:书前有罗马数字编号的序言;目录跨两页,第二页的标题被当成了正文标题参与匹配。整体偏移说明这不是模型问题,是解析问题。
解决:把build_outline里的 offset 计算改成打印全部候选值而不是只取众数,人工看一眼分布。如果候选值分散在两三个簇上,说明目录条目匹配本身有问题,改用"标题文本 + 字号"双条件匹配。另外在render里加一道page_max越界过滤,至少能拦住明显不合理的页码。
5.2 相邻章节的结论互相打架
现象:第 4 章说"作者主张渐进改革",第 5 章说"作者否定渐进路径",两段都带页码,查原文发现两边都对,作者确实在中间转了向。
原因:章节级归并是并行的,各章之间不通信,模型只看得到自己那一章的句子。等你把章节结果全书归并时,为了压到 12 条以内,它会把两条看起来相近的合并,立场差异就被抹平了。
解决:在merge_book的提示词里显式要求输出"立场变化"字段,允许它写"作者在第 4 章持 A,第 6 章转向 B"。别怕报告变长,这种转折恰恰是读书报告最有价值的部分。同时给归并加一条规则:页码跨度超过全书的 1/3 且主张相反的,不许合并,必须在limits里点明。
5.3 结构化输出在 80% 处截断成半截 JSON
现象:报错"unexpected end of JSON",或者解析出来arguments只有两条。日志里看原始返回,最后一行是断的。
原因:输出 token 上限撞到了。论点一多,模型每条证据都写得很长,很容易超出你设的上限。另一种情况是并发太高,接口侧做了截断。
解决:把max_tokens设到预期的 1.5 倍,同时在提示词里明确"每条论点最多两条证据、每句摘录不超过 120 字"。解析层加一层容错:先尝试json.loads,失败就截到最后一个完整的}再试一次,仍然失败才判为异常重跑。这一层容错能把失败率从百分之几压到千分之几。
5.4 书名、作者、译者被模型"脑补"
现象:报告开头的书目信息里,作者名多了个中间名,译者被写成了作者,或者副标题被补全成了一句通顺但原文没有的话。
原因:你把书目信息也交给模型生成了。模型见过大量同类书的版式,它会按概率补全,补得特别像真的——这是最隐蔽的一类翻车。
解决:书目信息一律从解析结果里取,取不到就留空写"未识别",绝不交给模型。在merge_book的提示词里明确"书目信息不得改写"。渲染阶段不要做任何"美化"处理,宁可留一个空格子,也不要一个漂亮但错的字段。
5.5 长篇书后半段质量明显塌陷
现象:前 5 章的论点具体、有细节,后 5 章的观点开始空泛,全是"进一步深化了……"。
原因:一是全书归并时章节论点按顺序塞进去,越靠后的越容易被压缩;二是抽取阶段后半部分的块摘要质量本来就低,如果提示词里给了示例,示例往往来自前几章,风格引导有偏。
解决:全书归并时不按章节顺序平铺,改成按"论点密度"排序——每章抽出的关键句数量除以篇幅,密度高的排前面。另外在提示词里加一句"每一章至少保留一条论点",强制覆盖。跑完之后对比各章的论点条数,如果末几章明显偏少,基本可以确认是这个问题。
6. 收尾这一步:用反向提问给报告打分
报告生成完不算完。我现在固定加一道校验:从终稿里抽 20 条带页码的断言,逐条回原文核对,命中率低于 80% 就打回重跑对应章节。
具体做法是把断言拆成(论点, 页码, 摘句)三元组,回到blocks.jsonl里找到该页附近的原文块,让模型做一个二选一的判断:摘句是否真实存在于该块中、论点是否被摘句支持。两个都为是算命中,任一为否算未命中。这一步的调用量只有 20 次,成本可以忽略,但它能拦住绝大部分页码错位和引文篡改。
如果不想引入额外调用,也可以做纯字符串匹配的粗筛:摘句去掉标点后在该页原文里的命中率低于 70%,直接标红。这个方法会漏掉同义改写的情况,但对"整句编造"这类问题非常敏感,做第一道筛子够用。
| 检查项 | 阈值 | 不达标时的动作 |
|---|---|---|
| 摘句原文命中率 | ≥ 80% | 重跑对应章节的抽取 |
| 页码越界比例 | ≤ 5% | 回查 offset 计算 |
| 论点条数 | 3~12 | 检查是否被截断 |
| 章节覆盖 | 每章 ≥ 1 条 | 调整归并排序策略 |
| 书目字段完整 | 4/4 | 回查解析结果,禁止模型补 |
还有一个更省事的技巧:先拿 20 页的样本跑通全流程,再放开整本。我现在的习惯是任何一本新书都先切出前 20 页加目录,走一遍解析、抽取、归并、渲染,确认页码对得上、章节树没歪,再放开跑全书。这个习惯是踩了几次"跑了四十分钟发现偏置错了"之后养成的,希望帮到你。
本文还有配套的精品资源,点击获取