做文献调研的人大概都有这种体验:为了确认一个方向最近有没有人做过,得在论文库里翻一下午,摘要一篇篇扫过去,看到第五篇就开始脸盲,最后收藏夹里躺了三十个链接,真正读完的两三篇。我自己也卡在这个环节很久,直到把 LangChain 的 arXiv 工具接进一套小流程里,才算把"检索、粗筛、精读、综述"这条链路里最枯燥的部分交出去。这篇就来聊怎么用 LangChain 的 arXiv 工具搭一个能用的科研助理:它能按主题和时间窗检索预印本、把结果整理成结构化的候选清单、挑出相关度高的几篇下载全文分段读、最后产出一份带编号引用的综述草稿。整套东西不依赖花哨框架,会一点 Python、能调通一个大模型接口就能跑起来,适合想练手工具调用的开发者,也适合每天要跟大量论文打交道的科研辅助场景。
1. 需求拆解:科研助理到底该替人干哪几件事
1.1 把"读文献"拆成五个可编排的动作
很多人一上来就想让模型"帮我读论文",这个目标太粗,落到代码里就没法执行。我的做法是先把人的动作拆开,拆到每一步都有明确的输入和输出为止。实际拆下来是这样五步:第一步按关键词和分类检索,拿到一批候选;第二步只读标题和摘要做粗筛,把明显不相关的剔掉;第三步对留下的几篇拉全文,按章节切段;第四步逐篇压缩成要点,再把要点合并成主题综述;第五步给每一条结论挂上论文编号,便于回查原文。
拆成五步的好处很直接。前两步成本极低,一次检索加一次摘要压缩,几十秒就能把范围从几百篇收敛到五篇左右;后三步才是真正烧 token 的地方,但因为前面已经收敛过了,整体开销可控。如果直接把"读论文"丢给模型,它要么凭记忆瞎编,要么把一堆摘要塞进上下文里做模糊概括,结果看着像那么回事,实际上一条都验证不了。
这五步里,前两步几乎完全能交给 arXiv 工具加重模型完成,第三步需要一点工程处理(下载、解析、切分),第四步是提示词设计的主战场,第五步靠后处理校验兜底。我建议新手先只做前两步,跑通之后再往后面接,不然一次性铺开五个环节,出问题根本不知道是哪一环。
1.2 现有工具的真实边界:arXiv 接口能给什么、不能给什么
动手前得先接受一个事实:arXiv 提供的是元数据和 PDF,不是一个现成的知识库。它的查询接口返回的是标题、作者、摘要、提交时间、分类号这些字段,正文得另外下 PDF 再解析。这一点决定了整个方案的基本形态——检索阶段是轻量的结构化数据,精读阶段是重量级的非结构化文本,两段的处理策略必须分开。
另一个边界是查询语法。它支持按标题、作者、摘要、分类、提交时间做字段限定,也支持布尔组合,但不支持语义检索。也就是说你输入"大模型在代码生成上的评测方法",它不会理解语义,只会拿这些词去做字面匹配。所以关键词怎么造,直接决定召回质量。我的经验是把一句自然语言需求拆成两到三组同义词,用分类号收窄领域,再叠加时间窗,比扔一整句话进去强得多。
还有一点容易被忽略:预印本没有经过同行评审,版本还会不断更新。同一个编号可能有 v1、v2、v3,内容差异不小。做科研助理时必须把版本号纳入去重逻辑,否则综述里会出现"同一篇论文被当成两篇"的低级错误。
1.3 架构选型:Agent 自主决策还是固定流水线
这是整个项目里第一个需要认真做决定的地方。两条路我都跑过,结论是分场景用,不要一刀切。
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Agent 自主调用 | 工具加执行器,模型自己决定何时检索、检索几次 | 交互自然,能处理追问和多轮修正 | 调用次数不可控,容易反复检索同一关键词,成本波动大 | 探索式调研、对话式问答 |
| 固定流水线 | 检索、粗筛、精读、综述顺序串联 | 每步可测、可缓存、成本可预估 | 灵活性差,用户改需求要改代码 | 批量调研、定期跟踪某个方向 |
| 混合 | 前置固定检索,后置 Agent 追问 | 主干可控,交互灵活 | 实现稍复杂 | 我个人最常用的形态 |
我最终采用的是混合形态:入口处用固定逻辑做一次检索和粗筛,把候选清单落到本地文件,然后把这个清单作为上下文交给 Agent,用户在对话里追问"第三篇的方法部分再展开讲讲",Agent 才会去调工具拉全文。这样主干路径的开销是确定的,交互部分才交给模型自由发挥。
选这个形态的核心理由是成本可控性。纯 Agent 方案里,模型有时候会因为一次检索结果不满意,连续调五六次工具,每次都带一轮完整的上下文,账单会明显跳一下。而固定流水线里,检索次数是写死的,缓存也能精确命中。
提示:如果你只是想验证工具能不能用,先写十行代码直接调工具跑一次检索,看返回长什么样。别一上来就搭 Agent,工具本身的返回格式没摸清楚,调 Agent 的时候会被各种意外输出带偏。
2. 核心组件拆解:三个层次别搞混
2.1 依赖安装与最小可运行目录
环境这块不复杂,但要提前把版本对齐。LangChain 生态拆包拆得比较细,社区包和核心包版本不匹配是最常见的报错来源。我的建议是新建虚拟环境,核心包和社区包一起装,装完先跑一个 import 自检。
python -m venv .venv source .venv/bin/activate pip install -U langchain langchain-community langchain-core pip install pypdf tenacity pydantic python-dotenv目录结构我习惯这样摆,后面加缓存和日志的时候不用改路径:
research-assistant/ ├── app.py # 入口,交互循环 ├── tools/ │ ├── search.py # arXiv 检索封装 │ └── reader.py # 全文下载与切分 ├── prompts/ │ ├── screen.md # 粗筛提示词 │ └── review.md # 综述提示词 ├── cache/ # 检索与解析结果缓存 ├── logs/ # 调用日志 └── .env # 模型服务凭据把提示词单独抽成文件是个小习惯,但收益很大。提示词是要反复调的东西,写在代码字符串里,改一次就得重启一次,还容易和逻辑代码混在一起看不清。抽成文件之后,改完直接重跑,比对两版输出差异很直观。
依赖里pypdf是给 PDF 解析用的,tenacity负责重试,别省这两个。预印本的 PDF 生成工具五花八门,解析失败是常态而不是异常,得留好重试和跳过逻辑。
2.2 检索层:包装器、工具、加载器是三样东西
初学者最容易在这里绕晕:ArxivAPIWrapper、ArxivQueryRun、ArxivLoader名字都带着 arXiv,但职责完全不同,混用会出各种怪问题。
ArxivAPIWrapper是底层封装,负责拼查询、发请求、把结果格式化成文本。ArxivQueryRun是在包装器外面套的一层"工具壳",它给包装器起了个名字、写了段描述,让 Agent 知道有这么个工具可用、什么时候该用。ArxivLoader走的是另一条路,它拿到编号之后去下载 PDF 并解析成文档对象,专门服务于"读全文"这个场景。
搞清这三者的关系,很多问题就自然有答案了。比如"为什么 Agent 返回的只有摘要没有正文"——因为它用的是工具壳,工具壳背后是包装器,包装器只给元数据。想要正文,必须让流程切到加载器。
检索封装的写法大概是这样:
from langchain_community.utilities.arxiv import ArxivAPIWrapper from langchain_community.tools.arxiv.tool import ArxivQueryRun arxiv_wrapper = ArxivAPIWrapper( top_k_results=6, # 单次返回条数 load_max_docs=6, # 内部文档数量上限 doc_content_chars_max=6000, # 每条结果的字符截断长度 sort_by="SubmittedDate", # 按提交时间排序 ) arxiv_tool = ArxivQueryRun( api_wrapper=arxiv_wrapper, name="arxiv_search", description=( "按关键词检索 arXiv 预印本,返回论文标题、作者、编号、提交时间和摘要。" "输入应当是一段检索表达式,可以用分类号和时间范围收窄。" "当你需要查找某个方向的最新论文时使用它。" ), )这里有两个参数值得单独说。top_k_results不是越大越好,超过十条之后,模型做粗筛的准确率会明显下滑,因为它倾向于把靠前的结果都当成相关。我一般设 5 到 8 条,一轮不满意就换个关键词再检一轮,比一次性拉 30 条效果好。
doc_content_chars_max控制每条结果被截断到多少字符。默认值偏保守,摘要稍长一点就会被切掉尾巴,导致模型判断相关度时缺信息。抬到五六千字符基本能覆盖完整摘要,代价是上下文占用变多,需要和top_k_results一起权衡。
sort_by用来控制排序策略,按相关度排还是按提交时间排。跟踪一个新方向时我一般选时间排序,因为刚出来的工作更能反映当前状态;查某个经典方法时选相关度排序更合适。
注意:工具的描述文本不是装饰,它是模型做选择的唯一依据。描述写得含糊,模型就会在不该调用的时候调用,或者该调用的时候自己去编。描述里最好写清楚"什么时候用""输入长什么样",这两点比罗列功能有用。
2.3 精读层:加载器的坑集中在解析和截断
加载器的调用很短,但坑很密:
from langchain_community.document_loaders import ArxivLoader loader = ArxivLoader( query="2401.00001", # 单个编号或编号列表 load_max_docs=1, doc_content_chars_max=300000, # 必须显式抬高,否则拿不到全文 ) docs = loader.load()第一个坑是截断长度。加载器的字符上限默认值偏小,很多人跑完发现拿到的还是摘要加一点点正文,以为是解析失败,其实是内容被截断了。想看全文就得把这个值调到一个远超实际篇幅的数,具体多少按你模型上下文预算反推。
第二个坑是 PDF 类型。如果某篇论文是扫描件或者用了特殊字体编码,解析器可能抽出空白或者乱码。这种情况没法在代码层面根治,只能做过检测:解析完判断正文长度和有效字符比例,低于阈值就跳过并记一条日志,别让脏数据流进后面的综述环节。
第三个坑是版本。同一个编号拉下来可能是最新版本,也可能是你上次缓存的旧版本。如果要做可复现的调研报告,最好在缓存键里带上版本号,别让同一份报告两次运行引用了不同内容。
2.4 模型与提示词的搭配:温度、预算和输出约束
模型这块我不绑定具体服务,用通用的初始化接口就行,换服务时只改一行:
from langchain.chat_models import init_chat_model llm = init_chat_model("provider:model-name", temperature=0)温度设 0 是硬性要求。科研助理的输出要能对得上原文,任何随机性都会让它在两个相近的表述之间反复横跳,同一份输入跑两次给出不同的相关度打分,后面就没法做阈值筛选了。
输出约束比提示词措辞更重要。粗筛这一步我不用自由文本,直接上结构化输出:
from pydantic import BaseModel, Field class PaperScore(BaseModel): arxiv_id: str = Field(description="论文编号,形如 2401.00001,不含版本号") relevance: int = Field(description="相关度,1 到 5 的整数,5 表示高度相关") reason: str = Field(description="不超过 40 字的相关性判断理由") method_keywords: list[str] = Field(description="论文使用的核心方法关键词,最多 3 个") class PaperScoreList(BaseModel): items: list[PaperScore]用列表包一层再交给with_structured_output,比直接声明list[PaperScore]稳,很多模型对裸列表的顶层结构支持不好。打分用整数而不是浮点,避免出现 3.7 和 3.8 这种没法设阈值的差别。
提示词里我只写三件事:判断标准、边界案例、禁止行为。判断标准要给具体的锚点,比如"5 分表示论文直接研究该主题并给出了新方法;3 分表示主题相关但方法不同;1 分表示仅背景提及"。边界案例给两三个实际遇到的模糊情况,比讲一堆抽象原则有用。
3. 完整实现:从一次检索到一份带引用的综述
3.1 第一段:让检索结果变成可筛选的清单
检索工具返回的是拼好的文本,直接喂给模型做粗筛,模型容易把不同论文的字段串在一起。我的做法是先用包装器拿到结构化结果,自己组装成清单,再交给模型。
import time from langchain_community.utilities.arxiv import ArxivAPIWrapper def search_papers(query: str, limit: int = 8) -> list[dict]: wrapper = ArxivAPIWrapper( top_k_results=limit, load_max_docs=limit, doc_content_chars_max=6000, sort_by="SubmittedDate", ) raw = wrapper.run(query) # 包装器返回的是拼接文本 # 生产环境建议直接用底层 http 拉 json,字段更干净 # 这里演示的是最短路径 return parse_raw(raw)实际工程里我会绕过文本拼接,直接请求接口拿 JSON,因为字段解析更可靠,也不用担心文本格式变化。但如果你只是想快速验证流程,用包装器的run也够用,先跑通再优化。
检索表达式怎么写,直接决定召回。几个实测有效的套路:
- 领域收窄用分类号:
cat:cs.CL表示计算语言学,cat:cs.LG表示机器学习。 - 短语匹配要加引号:
abs:"retrieval augmented generation"比裸词匹配准得多。 - 时间窗写在查询里:
submittedDate:[202401010000 TO 202412312359],这样服务端就帮你过滤了,不用本地再筛。 - 组合用布尔运算符:
abs:"agent" AND cat:cs.SE AND submittedDate:[...]。
一次没检到满意的结果,别急着让模型改关键词。我一般是手工准备三组备用查询,按顺序降级:第一组精确短语加分类,第二组放宽到摘要词,第三组只留分类加时间。三组都跑空,说明这个方向确实没新东西,这时候让模型硬编只会得到幻觉。
3.2 第二段:粗筛的阈值怎么定
拿到清单之后做批量打分,一次调用把所有条目送进去,让模型输出结构化结果。这里有个反直觉的点:不要把条目一条条单独调用。单条调用时模型没有对比基准,打分容易飘;一次性给全量,它会在内部做相对比较,结果更稳定。
阈值定在多少,取决于你对召回的容忍度。我的经验是首次调研设 3 分及以上,能留下三到五篇;如果是写综述做全覆盖,设 2 分及以上,接受一些边缘相关的内容,后面再人工剔。相关度判定之后,把原始元数据和打分结果合并存成一个 JSON 文件,这个文件是整个流程的中间态,后面所有环节都读它。
import json from pathlib import Path def save_candidates(scored, raw_items, path="cache/candidates.json"): by_id = {item["arxiv_id"].split("v")[0]: item for item in raw_items} merged = [] for s in scored.items: base = by_id.get(s.arxiv_id) if not base: continue # 白名单校验:模型编的编号直接丢 merged.append({**base, "score": s.relevance, "reason": s.reason}) merged.sort(key=lambda x: x["score"], reverse=True) Path(path).write_text(json.dumps(merged, ensure_ascii=False, indent=2)) return merged这段代码里最关键的不是排序,是那个if not base: continue。模型在结构化输出时偶尔会"补"出一个看起来合理但实际不存在的编号,这是个高频幻觉点。所有经过模型的编号都必须回到原始检索结果里做一次白名单比对,对不上的一律丢弃。这个习惯养成了,后面引用出错的情况能少一大半。
3.3 第三段:全文精读要解决的是上下文预算
到了精读这一步,问题就从"准不准"变成"塞不塞得下"。先做个粗略预算,心里有个数:
| 论文类型 | 英文词数量级 | 估算 token | 32k 窗口可容纳 | 128k 窗口可容纳 |
|---|---|---|---|---|
| 短篇快报 | 3000 至 5000 | 约 4k 至 7k | 3 到 4 篇 | 15 篇以上 |
| 常规长文 | 8000 至 12000 | 约 11k 至 16k | 2 篇 | 6 到 8 篇 |
| 综述类长文 | 20000 以上 | 28k 以上 | 1 篇还得切 | 4 篇左右 |
估算方法很简单:英文大约 1 个词对应 1.3 个 token,中文输出反过来,同样的信息量 token 数会更多。加上提示词、历史对话、系统消息这些固定开销,实际可用空间还要再打个八折。所以窗口是 32k 的模型,硬塞五篇全文一定会截断,而且是在你不知情的情况下截断,输出看着完整,实际只读了前半部分。
正确做法是分级处理。五篇以内且篇幅不长,可以直接整篇进上下文做一次横向对比,这种"全景式"输入对模型找共性最友好。超过五篇,或者单篇特别长,就走分篇压缩再合并的两段式:
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=6000, chunk_overlap=400, separators=["\n\n", "\n", ". ", " "], ) def summarize_paper(doc, llm): chunks = splitter.split_text(doc.page_content) partials = [] for chunk in chunks[:12]: # 硬性上限,防止超长论文拖垮流程 resp = llm.invoke(f"以下是论文片段,提炼方法与结论要点,保留关键数字:\n{chunk}") partials.append(resp.content) merged = "\n".join(partials) final = llm.invoke( "把下面的要点合并成一段结构化摘要,包含:研究问题、方法、数据集、" f"主要结论、局限性。不要引入要点里没有的信息。\n{merged}" ) return final.contentchunk_overlap设 400 是有讲究的。切分点如果正好落在关键句中间,前后两块各拿到半句,模型都理解不了。留出一段重叠,能让跨块的句子至少在一块里完整出现。重叠太小没效果,太大又会让总 token 数膨胀,四百到八百字符是我试过的比较舒服的区间。
chunks[:12]这个上限也很重要。有些论文附录特别长,切出三四十块,全跑一遍既费钱又没必要,因为附录对理解核心方法的贡献很低。截断前十二块,基本能覆盖摘要、引言、方法、实验四大部分。
3.4 第四段:综述生成与引用绑定
综述环节最容易出的问题是"综述写得很顺,但每句话都找不到出处"。解决办法不是在提示词里反复强调"要引用",而是把引用变成结构约束:输入时给每篇论文打上固定标签,输出时要求每段结尾标注标签,最后做一次标签校验。
你将收到编号为 [P1] 到 [P5] 的论文摘要。 请写一段 600 字左右的主题综述,按"问题现状、主流方法、分歧点、空白"四部分组织。 要求: 1. 每一段结尾用方括号标注该段依据的论文标签,例如 [P2][P4]。 2. 只能使用输入中出现的信息,不得补充外部知识。 3. 如果某一部分没有足够材料,直接写"输入材料不足",不要推测。三条要求里,第三条最有用。模型在被要求"写完整"时会倾向于补全,允许它说"材料不足",幻觉率会明显下降。这算是个小技巧:给它一个合法的"不会"选项,它就不用编了。
生成完之后跑一遍校验:
import re def check_citations(text: str, valid_labels: set[str]) -> list[str]: used = set(re.findall(r"\[P\d+\]", text)) return sorted(used - valid_labels) # 返回越界的标签返回空列表说明引用都在白名单里,返回非空就说明模型编了标签,得回炉重写或者人工修。这套校验很土,但比任何提示词约束都可靠。
综述的粒度也要控制。一次让它覆盖五个方向、写三千字,结果一定是每个方向都浅尝辄止。我一般一次只做一个主题,六到八百字,把深度做足。多个主题就多跑几轮,每轮换一组论文,最后人工串起来——这活儿交给人比交给模型靠谱。
3.5 第五段:接上 Agent 做交互式追问
前面四段都是批处理,跑完出文件。如果你想要对话式的体验,就在这个基础上套一层 Agent。核心是把"检索"和"读全文"做成两个工具,让模型自己选。
from langchain.agents import AgentExecutor, create_tool_calling_agent from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一名科研助理。回答涉及具体论文的问题前,先调用检索工具获取事实。" "禁止凭记忆给出论文标题、作者或编号。" "如果检索结果不足以回答,明确说明缺什么信息。"), ("placeholder", "{chat_history}"), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_tool_calling_agent(llm, [arxiv_tool, reader_tool], prompt) executor = AgentExecutor( agent=agent, tools=[arxiv_tool, reader_tool], max_iterations=6, early_stopping_method="generate", verbose=True, handle_parsing_errors=True, )max_iterations=6和early_stopping_method="generate"这两个参数是我踩坑之后加的。默认迭代次数偏高,模型有时候会陷入"检索、觉得不够、再检索"的循环,每次检索都带一轮完整上下文,跑一轮下来成本翻几倍。限到六次,超了就直接用现有信息生成答案,虽然可能不完美,但至少不会失控。
verbose=True在开发阶段一定打开。它会打印每一步的思考、工具名、工具输入,你能一眼看出模型是不是在瞎调工具、查询语句写得对不对。上线前再关掉。
4. 问题排查实录:那些文档里不会写的情况
4.1 检索为空或结果完全不相关
这是最高频的问题,八成出在查询表达式上。很多人直接把一句自然语言丢进去,服务端做字面匹配,长句里全是停用词,匹配效果自然差。
排查顺序我固定为三步。第一步,把查询缩到两三个核心词,去掉所有修饰语,看有没有结果;有结果说明是表达式的锅,没结果说明是真没内容。第二步,去掉分类号限制再试,有时候分类号选错(比如该用cs.CL结果写了cs.AI),会把相关结果全过滤掉。第三步,换成作者或者已知论文编号反查,确认接口本身是通的。
还有一种情况是时间窗写错。日期格式要求是十四位数字的起止时间,写成2024-01-01这种会被静默忽略,你以为限了时间,其实没限,返回一堆旧论文。
提示:调试阶段把工具返回的原始文本直接打印出来看,别隔着模型看。模型会把空结果描述成"未找到完全匹配的内容",而实际可能是接口报了个参数错误,你完全看不出来。
4.2 调用超时与限流
批量拉全文的时候必然会遇到。表现是某个编号的请求卡住十几秒然后抛异常,或者连着拉十几个之后开始连续失败。这是服务端的保护机制在起作用,不是你的代码写错了。
处理办法有三层。第一层是间隔:批量请求之间固定休眠几秒,别并发猛拉。官方文档里对访问频率有明确要求,按规矩来最省事。第二层是重试:用指数退避重试三次,第一次等两秒,第二次等四秒,第三次等八秒,大部分抖动都能扛过去。
from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=15)) def fetch_fulltext(arxiv_id: str): loader = ArxivLoader(query=arxiv_id, load_max_docs=1, doc_content_chars_max=300000) docs = loader.load() if not docs or len(docs[0].page_content) < 2000: raise ValueError(f"解析内容过短,可能是扫描件:{arxiv_id}") return docs[0]第三层是跳过加记录。三次重试都失败的,写进一个失败清单,最后统一报告,别让单个失败卡住整批任务。我一般会在跑之前先把所有编号列出来,跑完对比前后的数量,少了哪几个一目了然。
4.3 模型给出的论文信息对不上原文
这个问题的表现很隐蔽:综述读起来毫无破绽,但你去核对,发现作者名拼错了、数据集名字换了、结论强度被夸大了。三种来源,得分开治。
第一种是编号幻觉,模型生成了不存在的编号。前面说的白名单校验就是治它的。第二种是内容幻觉,编号对了但细节编了。治它的办法是要求逐句可溯源,让模型在提炼要点时必须摘原文短句,而不是自己转述。第三种是跨论文串味,把 A 论文的方法安到 B 论文上。这种情况多发于一次性塞太多篇进上下文,篇数降到三到四篇就明显好转。
我个人的红线是:任何进入正式文档的结论,必须能在我本地缓存的原文里搜到对应句子。搜不到就退回重做,不管综述写得多漂亮。这条规则听起来笨,但它是唯一能保证输出可信的办法。
4.4 长文本处理中的静默截断
这个坑最难发现,因为程序不报错,输出也完整。表现是综述里只讨论了论文的前半部分,后半部分的实验和局限性完全没提。原因就是字符上限或上下文窗口在某个环节把内容切了,而且切得很干净,看不出痕迹。
排查方法是打印每篇论文进模型之前的字符数,和原始解析出来的字符数做对比。两个数差得多,说明中间有截断;一样多,说明截断发生在模型侧(上下文窗口满了),那就得减少篇数或者加压缩步骤。
我现在的做法是每一步都记一条日志:输入字符数、输出字符数、耗时、调用次数。日志看起来枯燥,但出问题时能十秒定位到是哪一段吃的亏。
4.5 常见问题速查表
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 检索返回空 | 查询表达式含停用词、分类号写错 | 缩到两三个核心词,去掉分类号再试 |
| 时间过滤没生效 | 日期格式不是十四位数字 | 改成YYYYMMDDHHMM的起止格式 |
| 只有摘要没有正文 | 用了工具壳而非加载器 | 切换到全文加载器并抬高字符上限 |
| 正文抽出空白 | PDF 是扫描件或无文本层 | 检测长度低于阈值即跳过并记录 |
| 同一篇出现两次 | 版本号未去重 | 统一按主干编号归一化 |
| 综述引用编号越界 | 模型编造标签 | 输出后做白名单校验,越界即重写 |
| Agent 反复调工具 | 迭代上限太高 | 限到六次,超出直接用现有信息生成 |
| 批量拉取连续失败 | 触发服务端频率保护 | 加固定间隔,配合指数退避重试 |
| 输出每次都不一样 | 温度未归零 | 生成类调用统一设 0 |
| 上下文莫名变短 | 窗口满导致静默截断 | 记录输入字符数,与原文对比 |
5. 让它能长期跑:缓存、日志和一点工程习惯
5.1 两级缓存能省掉大半重复开销
调研这件事的特点是反复查同一批东西。今天查一遍,明天补两个关键词再查一遍,中间大量论文是重复的。不做缓存,每次都要重新下载解析,既慢又容易触发频率限制。
我设了两级。第一级是检索缓存,键是查询表达式加条数的哈希,值是一段时间内的检索结果。第二级是全文缓存,键是论文编号加版本号,值是解析后的纯文本。两级分开是因为失效策略不同:检索结果可能因为新论文出现而过时,我设的是当天有效;全文内容基本不变,只在版本号变化时失效。
import hashlib, json from pathlib import Path CACHE = Path("cache") def cache_key(*parts: str) -> str: raw = "|".join(parts) return hashlib.md5(raw.encode()).hexdigest() def cached_json(key: str, producer, ttl_days: int = 1): path = CACHE / f"{key}.json" if path.exists(): return json.loads(path.read_text()) data = producer() path.write_text(json.dumps(data, ensure_ascii=False)) return data这套东西不到二十行,但把开发迭代的速度提了一大截。调提示词的时候最怕的就是每改一个字都要等半分钟的网络请求,有了缓存,改完立刻能看到效果。
5.2 日志要记到能复现的程度
我给自己定的标准是:任何一次输出,都能根据日志复现出来。所以日志里至少要包含这些字段——查询表达式、命中条数、入选编号列表、每篇的原文长度、每次模型调用的输入输出字符数、耗时、错误信息。
这些字段里最容易被省略的是"输入字符数"。很多人只记耗时和结果,出问题时完全不知道当时塞了多少内容进去。把字符数记下来,配合前面的预算表,一眼就能判断是不是超了窗口。
另一个习惯是把中间产物落盘而不是只在内存里传。候选清单、分篇摘要、最终综述、引用校验结果,全部落成文件,按时间戳分目录。这样做的好处是任何一步出问题,都可以从最近的一个中间产物重新跑,不用从头来。调提示词的时候尤其明显,一篇论文的摘要压缩可能反复改十几版,每次都重新下载解析,时间全浪费在等网络上。
还有一点值得提:把提示词版本号也写进日志。同一份输入,提示词改一个字,输出可能差很远。不记版本号,过两天你看到一份好输出,想复现都找不到当时用的是哪版提示词。
我个人搭这类小工具最大的体会是,真正花时间的从来不是写代码,是搞清楚每个环节的真实边界——接口能给什么、模型在什么情况下会编、上下文到底够不够。这些东西没有文档会写,只能自己一条条试出来,然后把它固化成校验和日志。工具本身跑通只要一个下午,让它可靠地跑上几个月,靠的全是这些看起来笨拙的兜底。