项目名称:ScholarCraft
定位:一个本地与远端混合模型驱动的科研调研Agent,能自主规划、调用工具、阅读论文并生成结构化报告。
技术栈:LangGraph + Ollama (Qwen3-4B) + 小米 MiMo API
进度:论文库扩充至26篇并补全核心指标、三层记忆系统完整实现、上下文管理与安全机制落地

前言

在上一篇文章的结尾,我预告了接下来要为ScholarCraft构建三层记忆系统。但在真正动手写记忆代码之前,还有一项更基础的工作要做——把本地论文库从3篇种子论文扩充到真正能支撑多方向对比分析的规模。

这篇文章记录的就是这个过程,以及记忆系统和上下文工程的完整实现。三块工作加起来,解决了Agent从“能搜”到“能记”再到“能管”的全链路问题。写完这篇回头一看,发现它们其实指向同一个目标:让Agent在面对不可靠的外部环境时,依然能稳定地产出可信任的结果。


一、论文库深度整理:从Zotero到结构化JSON

第二篇文章里我提了一句“论文库从3篇扩充到26篇”,但那句话背后是一整套数据清洗流程。这里把具体做法展开说说。

1.1 为什么要从Zotero导出?

我之前在Zotero里积累了大量分子生成相关的论文,包括经典方法论文、综述、评测基准等。但Zotero的数据库和ScholarCraft需要的papers.json格式完全不同。ScholarCraft要求的字段包括method(方法名)、dataset(使用的数据集)、key_metric(关键指标数值)、comparison_baseline(对比基线),这些信息在Zotero里是没有的。

所以整个流程是:先用Zotero导出文献元数据(标题、摘要、作者、年份等),写转换脚本把格式对齐,然后用LLM自动填充缺失的关键字段,最后手工补全核心论文的定量指标。

1.2 Zotero导出:选对格式很重要

Zotero的导出格式有好几种。我最初试了CSL JSON,但发现它不包含摘要和关键词字段——这两个字段对于后续的LLM自动填充至关重要。换成BetterBibTeX JSON之后,摘要字段(abstractNote)和关键词字段(tags)都有了。

导出时勾选Keep updated,这样以后Zotero里改了文献信息重新导出时会更方便。

导出的JSON里每个条目长这样:

json

{
  "itemType": "journalArticle",
  "title": "Diffusion Models in De Novo Drug Design",
  "abstractNote": "Diffusion models have emerged as powerful tools...",
  "creators": [
    {"firstName": "Amira", "lastName": "Alakhdar", "creatorType": "author"}
  ],
  "date": "2024-10-14",
  "tags": [{"tag": "Computer Science - Machine Learning"}],
  "DOI": "10.1021/acs.jcim.4c01107"
}
1.3 写转换脚本convert_zotero.py

这个过程碰到了几个麻烦。第一个麻烦是字段名不匹配——Zotero用creators而不是authors,名字是firstName/lastName而不是given/family,日期不是年份而是完整的日期字符串"2024-10-14"。摘要叫abstractNote而不是abstract。转换脚本需要逐个字段做映射。

第二个麻烦是过滤。Zotero导出的JSON里除了期刊论文和预印本,还混入了attachment(附件条目)和note(笔记条目),需要在代码里过滤掉,只保留itemTypejournalArticlepreprint的记录。

第三个麻烦来自arXiv早期论文的ID重复问题。有一篇CGVAE论文的arXiv ID和一篇5G通信论文恰好相同(1802.08776),Zotero抓取时匹配到了通信方向的那篇。这种需要手工剔除。

最终产出的papers.json包含id、title、authors、year、abstract、keywords、method、dataset、key_metric、comparison_baseline、arxiv_id等字段。method和key_metric此时还是空的,需要下一步填充。

1.4 用MiMo自动填充method、dataset、key_metric

写了一个enrich_papers.py脚本,逻辑是读入papers.json,对每篇缺少method或key_metric的论文,用MiMo API根据标题和摘要提取方法名、数据集、关键指标。

但批量调用时踩了一个坑:MiMo API在接收连续20+次调用后,开始大量返回空响应。不是报错,而是HTTP状态码200但响应body为空字符串。这让我一开始以为是Prompt的问题,换成英文Prompt后效果稍好但仍不稳定。后来才意识到这是API的隐形限流机制——不是返回429,而是直接给空内容让客户端自己处理。

解决思路是增加重试机制(最多3次,间隔递增),并在JSON解析失败时打印原始响应内容方便调试。但即使这样,部分论文的填充仍然失败。原因是很多综述论文的摘要里根本没有具体指标数值,MiMo返回空JSON是合理的,不是API的问题。

最终的务实方案是手工补几篇核心方法论文的指标数据。EDM的validity是0.963,FreeGress的MAE提升是79%,DiGress的validity提升了3倍——这些数据是固定的、公开的,直接填进去一劳永逸。

几篇缺失摘要的论文也需要手工补全。GuacaMol、Automatic chemical design、深度学习3D分子生成综述的摘要在Zotero导出时是空的,从arXiv或原文中提取后手工填入。


二、三层记忆系统

论文库是知识储备,记忆系统是上下文感知。当前Agent每次调研都是独立的,不知道用户偏好什么方向、之前调研过什么内容、常用什么对比方法。三层记忆系统要解决的就是这个问题。

2.1 工作记忆(Working Memory)——不需要额外开发的“地基”

这一层就是LangGraph的AgentState——plan(步骤列表)、current_step_index(当前进度)、tool_results(工具返回结果)、errors(错误记录)。单次任务内有效,任务结束后自然丢弃。它不需要持久化,也不需要任何额外开发,是记忆系统的基础层。

2.2 短期记忆(Short-Term Memory)——同一会话内共享上下文

这一层要解决两个问题:State持久化(程序中断后能从断点恢复),以及防止Token爆炸(多轮搜索后上下文膨胀)。

2.2.1 State持久化:接入SqliteSaver的踩坑

LangGraph原生支持Checkpointer机制。理论上只需要在编译graph时传入SqliteSaver实例,每次调用后State就会自动保存到SQLite文件中。

但实际接入时遇到了一个坑。新版本的langgraph-checkpoint-sqlite中,SqliteSaver.from_conn_string()返回的是一个异步上下文管理器,不能直接用在同步的build_graph函数中。报错信息是:

text

TypeError: Invalid checkpointer provided. Expected an instance of BaseCheckpointSaver

正确的做法是手动创建sqlite3.Connection对象,直接传给SqliteSaver的构造函数:

python

import sqlite3
conn = sqlite3.connect(db_path, check_same_thread=False)
checkpointer = SqliteSaver(conn)

同时在app.py中传入thread_id作为会话标识:

python

import sqlite3
conn = sqlite3.connect(db_path, check_same_thread=False)
checkpointer = SqliteSaver(conn)

同一个thread_id下的多次调用会自动共享State。第一次调研的搜索结果,第二次追问时可以直接从checkpoint中恢复。

2.2.2 滑动窗口+摘要压缩

多轮搜索-评估-重试的循环中,messages列表会迅速膨胀。当消息超过8条时,自动触发压缩——用本地LLM将超出最近5轮的历史压缩为一段200字以内的摘要,保留研究方向、已调研的论文列表、偏好对比方式等关键信息,丢弃冗余的原始工具输出。

压缩函数的核心逻辑:

python

def compress_history(messages: list, max_turns: int = 5) -> list:
    if len(messages) <= max_turns:
        return messages
    overflow = messages[:-max_turns]
    recent = messages[-max_turns:]
    # 用本地LLM对溢出部分做摘要
    summary = ollama_chat(...)
    return [{"role": "system", "content": f"历史对话摘要:{summary}"}] + recent
2.3 长期记忆(Long-Term Memory)——跨会话记住用户偏好

短期记忆解决同一会话内的问题,长期记忆解决跨会话的问题。关闭终端再打开,Agent还能记得用户的研究偏好。

2.3.1 双存储架构:SQLite + ChromaDB

当前LangGraph生态中有多种长期记忆方案,但对个人项目来说,SQLite加ChromaDB的零配置组合性价比最高。

SQLite负责存储结构化的用户偏好事实,表结构很简单:

sql

CREATE TABLE user_preferences (
    id INTEGER PRIMARY KEY,
    preference TEXT NOT NULL,
    category TEXT DEFAULT 'general',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)

每条记录是一个简短的偏好陈述句,比如“用户研究方向为分子生成,偏好扩散模型方法”或“用户关注QM9数据集上的validity指标”。

ChromaDB负责语义检索。每次存储偏好时,同时用本地嵌入模型nomic-embed-text生成768维向量,存入ChromaDB。当用户发起新调研时,用当前查询的embedding去匹配最相关的历史偏好。

2.3.2 非侵入式更新

每次调研结束后,后台自动调用本地LLM从本次对话中提取新的用户偏好,写入SQLite和ChromaDB。这个过程不阻塞Agent主流程,提取失败也不会影响报告生成。

更新函数的核心逻辑:

python

def update_memory(user_query: str, report_content: str):
    # 提取偏好
    preferences = extract_preferences(user_query, report_content[:500])
    # 存储到 SQLite 和 ChromaDB
    store_preferences(preferences)
2.3.3 验证测试

跑了一次完整的调研后,用测试脚本验证:

bash

查询: 帮我调研扩散模型的最新进展
  匹配偏好 1: 用户关注分子生成模型在真实生物活性或毒性等下游任务上的有效性验证
  匹配偏好 2: 用户研究方向为分子生成,偏好扩散模型方法
  匹配偏好 3: 用户关注分类器自由引导机制对分子结构合理性的提升

查询: 对比扩散模型和GAN在分子生成中的表现
  匹配偏好 1: 用户关注基于欧几里得变换等变的3D分子生成模型
  匹配偏好 2: 用户关注分子生成模型在真实生物活性或毒性等下游任务上的有效性验证
  匹配偏好 3: 用户研究方向为分子生成,偏好扩散模型方法

语义检索按相关性排序的结果合理。与扩散模型相关的查询(第一条和第三条)都能匹配到精准的偏好,偏好注入到report_node的Prompt后,生成的报告中会主动对比QM9数据集上的表现、关注下游任务有效性验证。


三、上下文工程与安全机制

记忆系统让Agent知道用户偏好什么。但还有一个问题没解决:当搜索结果特别多或者对话轮数特别长时,Token消耗会超出本地模型的上下文窗口。加上一些安全机制,才算补全了“可靠性”的最后一块拼图。

3.1 ContextManager:Token预检与工具返回筛选

多轮搜索下来,tool_results里可能堆积了几十篇论文的完整信息——每篇都有标题、作者列表、几百字的摘要、关键词数组等。这些全塞进Prompt里,很快就能填满Qwen3-4B的上下文窗口。

实现的ContextManager做了两件事。

第一是Token估算。用简单的字符比例计算:中文字符约1.5字符/token,英文约4字符/token。设了一个3000 token的阈值,超出就触发压缩。

第二是论文信息精简。compress_papers函数把每篇论文从完整字段压缩为只保留id、title、method、dataset、key_metric、source六个关键字段。标题截断到100字,完整摘要直接丢弃——因为到了报告生成阶段,具体指标已经由method和key_metric承载了,不需要再保留大段文字。

这里也踩了一个小坑。在序列化messages时,里面存放的是LangChain的AIMessage对象,直接json.dumps会报错:

text

Object of type AIMessage is not JSON serializable

解决方法是给json.dumps加上default=str参数。这样遇到无法序列化的对象时,会自动调用其__str__方法。AIMessage的字符串表示正好是它的content文本,完美满足Token估算的需求。

一次真实的运行日志展示了压缩效果。Agent在步骤2的补充搜索后积累了大量论文数据,触发了上下文压缩:

text

[CTX] 上下文过长,压缩 tool_results...

随后的步骤3到步骤5没有再出现压缩异常,整个过程自动完成,用户无感知。

3.2 熔断机制

如果某个外部依赖持续不可用,系统不应该反复重试浪费API额度。实现了一个简单的熔断器。

tool_call_node中增加了失败计数器tool_failures,按工具名分别记录连续失败次数。阈值设为3次,与evaluate_node的重试上限保持一致。连续失败达到阈值后,自动跳过对该工具的后续调用,返回一个包含失败次数的诊断信息。当同一步骤的工具调用成功后,该工具的失败计数自动重置。

虽然正常情况下熔断不会被触发,但这个机制的存在本身就是系统韧性的重要体现。

3.3 工具权限模型

在安全层面,定义了每个MCP工具的权限类型:

工具 权限
search_local_papers read
read_paper_detail read
save_report write
search_arxiv network
supplement_metadata network

在编排层执行工具前检查权限,不合规的直接拒绝。当前版本默认允许所有操作,但框架已经预留了接入认证和授权模块的能力。这套轻量级的权限模型参考了行业安全标准,面试时可以展示对Agent安全维度的考虑。


四、端到端测试

运行python app.py "帮我调研分子生成中的应用方法有哪些?",整个流程零错误完成。

这次的报告中,方法对比表格首次出现了具体的指标数值。EDM的validity 0.963、FreeGress的MAE提升79%、DiGress的validity提升3倍,这些数据都来自手工补全的本地论文库。所有缺失信息诚实标注为“未提供”,没有编造。

日志中出现了[CTX] 上下文过长,压缩 tool_results...——这是上下文管理机制在自动工作。长期记忆成功存储了4条偏好:用户关注下游任务的有效性验证、关注等变模型在3D分子生成中的应用、关注分类器自由引导机制、研究方向为扩散模型分子生成。


五、总结与下一步

本文记录了ScholarCraft在论文库深度整理、记忆系统和上下文工程三个方向上的全部工作。

论文库从3篇种子数据扩充到26篇结构化文献,覆盖扩散模型、VAE、GAN、评测基准、综述等多个方向。核心方法论文的定量指标全部补全,本地搜索的返回量稳定在9-12篇,相关性评分达到满分10/10。

三层记忆系统中,短期记忆通过SqliteSaver实现了State自动持久化和会话中断恢复,滑动窗口加摘要压缩防止了Token爆炸。长期记忆用SQLite加ChromaDB实现了跨会话的用户偏好存储和语义检索,每次调研结束后后台自动更新。

上下文工程方面,ContextManager提供了Token预检和工具返回筛选能力,熔断机制防止外部依赖异常导致系统崩溃,工具权限模型为安全管控预留了架构基础。

当前系统已经具备了任务规划、多角度搜索、本地加联网检索、迭代重试与相关性评估、完整的工具调用链、结构化报告输出、短期记忆与长期记忆、以及上下文管理与安全机制。从“能搜”到“能记”再到“能管”,Agent的工程成熟度有了实质性的提升。

在下一篇文章中,我将为ScholarCraft搭建完整的观测与评测体系。具体包括实现全链路Trace日志,让每一步的输入输出和耗时都可回溯,以及设计15个分级测试用例配合自动化评测脚本,产出任务完成率、工具调用成功率等量化指标。这些数据在面试中的说服力远超“我觉得还行”。

完整的项目代码同步更新在GitHub,欢迎关注和交流。本文是ScholarCraft系列的第三篇,后续文章将陆续更新。如果有任何工程落地上的问题或建议,欢迎在评论区讨论。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐