在 Codex 协助下用 TypeScript 重构 Hello-Agents:Agent、RAG、Memory 与文档学习助手
本文对应仓库:
AgentLearn。这个学习项目是在 Codex 协助下完成的:我以 Hello-Agents 为学习主线,与 Codex 协作分析原理、设计 TypeScript 架构、实现代码、补充测试并整理文档,最终完成了 Agent 基础库、文档问答后端和 React 前端。文中凡是提到"本项目实现"或"我们实现",均包含 Codex 的辅助参与,并非宣称全部代码由我脱离 AI 工具独立手写。
前言:为什么要用 TypeScript 重写 Hello-Agents
本项目的主要学习来源是 Datawhale 开源教程 Hello-Agents。Hello-Agents 的目标不是教读者套用某一个现成框架,而是从大语言模型、工具和 Agent 的基本关系出发,逐步实现 ReAct、Plan-and-Solve、Reflection、Memory、RAG、上下文工程,以及 MCP、A2A、ANP 等能力。它强调从原理走向实现,这也是我选择它作为学习主线,并借助 Codex 将这些知识落实为 TypeScript 工程的原因。
上游项目主要使用 Python 展示概念和框架实现,本仓库则在 Codex 协助下换成 Node.js 与严格 TypeScript 重新组织代码。这里的"重写"不是逐行翻译:我与 Codex 先梳理上游思想,再结合 TypeScript 生态讨论并实现类型契约、错误处理、存储边界、测试方式和前后端协作。主要对应关系如下:
| Hello-Agents 学习主题 | 本项目在 Codex 协助下的实现 |
|---|---|
| LLM、Message、Agent 基础抽象 | agent-patterns-ts/src/core/ |
| ReAct、Plan-and-Solve、Reflection 等经典范式 | agent-patterns-ts/src/agents/ |
| Tool System 与 Function Calling | agent-patterns-ts/src/tools/ |
| 四类 Memory 与 RAG | agent-patterns-ts/src/memory/、src/rag/ |
| Gather-Select-Structure-Compress 上下文工程 | agent-patterns-ts/src/context/ |
| 结构化笔记 | agent-patterns-ts/src/notes/ |
| MCP、A2A、ANP | agent-patterns-ts/src/protocols/ |
| 可交互的学习助手案例 | document-qa-assistant/ 与 document-qa-web/ |
为了方便继续阅读上游材料,可以重点参考官方教程的经典 Agent 范式、自研 Agent 框架、Memory 与 RAG、上下文工程和智能体通信协议。
经过多轮代码生成、审阅、测试和调整,最终得到的并不只是一个 Agent 类,而是三个彼此独立又能协作的 TypeScript 项目。这个结果来自我与 Codex 的协作开发:
agent-patterns-ts:可复用的 Agent 基础库。document-qa-assistant:组装 Agent、RAG、Memory 和 PDF 解析能力的 Fastify 后端。document-qa-web:覆盖上传、问答、对话、笔记、进度和报告的 React 前端。
这三个项目没有组成根目录 npm workspace,各自拥有独立的 package.json、依赖、测试和构建流程。这样的结构虽然需要分别安装依赖,却能清楚展示"基础库---业务后端---交互前端"三层之间的依赖方向。
一、整体项目架构
下面这套整体架构也是在 Codex 协助下逐步拆分和实现的。先从一次真实请求看全局:用户在浏览器上传 PDF,前端把文件交给 Fastify;后端解析文本并写入 RAG;提问时,检索器从 Qdrant 找候选片段,再从 SQLite 读取权威文本和元数据;LLM 基于检索上下文生成带引用回答;学习过程中的文档加载、问答和笔记又会进入 Memory,最终汇总成学习报告。
1. 三个子项目的职责边界
| 子项目 | 技术栈 | 负责什么 | 不负责什么 |
|---|---|---|---|
agent-patterns-ts |
TypeScript、OpenAI SDK、Zod、SQLite、Qdrant、Neo4j | Agent 抽象、工具、Memory、RAG、Context、Notes、Protocols | HTTP 路由和具体页面 |
document-qa-assistant |
Fastify、pdfjs-dist、底层 Agent 包 | 配置装配、PDF 安全转换、业务编排、API、报告 | 浏览器交互 |
document-qa-web |
React 19、Vite、Testing Library | 状态管理、请求封装、结果展示、交互与可访问性 | PDF 解析、向量检索、模型调用和密钥保存 |
2. 为什么 RAG、Memory 要使用不同存储空间
RAG 保存的是外部资料,Memory 保存的是用户经历、问题、笔记和偏好。两者都需要语义检索,但数据生命周期和语义完全不同:
- RAG 的基本单位是
document + chunk,关注来源、章节、字符偏移和引用。 - Memory 的基本单位是
MemoryItem,关注用户、记忆类型、时间和重要度。 - 两者可以共用一个 Qdrant 服务,但必须使用不同 collection。
- 两者在本项目中也使用独立 SQLite 文件,便于备份、排错和演进。
后端配置加载时会主动检查 QDRANT_COLLECTION !== RAG_QDRANT_COLLECTION,从启动阶段阻止数据混写。
3. 项目目录
text
AgentLearn/
├── agent-patterns-ts/ # 可发布、可复用的 Agent 能力包
├── document-qa-assistant/ # Fastify 文档问答后端
├── document-qa-web/ # React/Vite 学习工作台
├── AGENTS.md # 仓库开发规范
├── README.md # 快速说明
└── tutorial.md # 本文
下面依次进入三个子项目。每个部分都会先解释核心代码,再完整串联主要实现流程。
二、子项目一:agent-patterns-ts------Agent 能力基础库
agent-patterns-ts 是整个仓库的地基,npm 包名为 @ericstone/agent-patterns-ts。这个子项目由我在学习 Hello-Agents 核心原理的过程中,借助 Codex 完成模块拆分、TypeScript 实现、测试补充和工程化调整。它采用 ESM 和严格 TypeScript,并通过 package.json 的 exports 暴露根入口及 core、agents、tools、memory、rag、context、notes、protocols 子路径。
text
agent-patterns-ts/src/
├── core/ # LLM、Message、Agent、配置和错误
├── agents/ # 六种 Agent 范式
├── tools/ # 工具注册、执行与适配
├── memory/ # 四类记忆及生产存储
├── rag/ # 文档摄取、检索、引用回答
├── context/ # 上下文构建
├── notes/ # Markdown 笔记
├── protocols/ # MCP、A2A、ANP
├── extensions/ # 自定义扩展示例
├── examples/ # 可运行示例
└── index.ts # 统一导出
2.1 Core:先稳定 Agent 与模型之间的契约
核心接口刻意保持很小:
ts
export interface LlmClient {
readonly provider?: string;
readonly model?: string;
generate(messages: MessageData[], temperature?: number): Promise<string>;
}
export interface AgentResult {
answer: string;
steps: number;
}
这段设计的价值在于,所有 Agent 只依赖 LlmClient,不直接依赖 OpenAI SDK。测试可以传入 Fake LLM,未来也可以替换为其他 SDK,而不用修改 Agent 的推理流程。
抽象类 Agent 进一步统一了四件事:名称、系统提示词、运行配置和会话历史。子类只需实现 run()。buildBaseMessages() 会把系统提示词与历史记录转换为模型可接受的最小消息结构;getHistory() 返回数组副本,避免外部直接篡改内部状态。
Message 使用 Zod 校验 role、content、timestamp 和 metadata,并冻结 metadata 的副本。这看起来只是数据类,却给后续所有 Agent 提供了稳定历史格式。
真正调用模型的是 HelloAgentsLlm:
resolveLlmConfig()负责 Provider、模型、Base URL、API Key、超时和 Token 参数解析。invoke()完成普通 Chat Completions。streamInvoke()以AsyncGenerator<string>暴露流式结果。createToolCompletion()保留 OpenAI 原生tool_calls结构,供FunctionCallAgent使用。- SDK 异常被统一包装成
LlmInvocationError,上层不需要识别不同厂商的错误对象。
因此,核心依赖方向是:
text
Agent -> LlmClient 接口 <- HelloAgentsLlm -> OpenAI-compatible API
2.2 Tools:把模型能做的动作收进受控边界
工具的核心接口同样很小:
ts
export interface Tool<TInput = unknown> {
name: string;
description: string;
inputSchema: ZodType<TInput>;
execute(input: TInput): Promise<string>;
}
ToolRegistry 是工具系统的中心。注册工具时,它把泛型工具封装为只接收 unknown 的内部对象;执行时先用 Zod 校验参数,再捕获执行异常,最后返回统一的文本结果。这样模型即使传入错误 JSON、漏字段或调用未知工具,错误也会作为 Observation 返回,而不是直接破坏 Agent 循环。
toOpenAiTools() 会把 Zod Schema 转成 JSON Schema,并要求顶层必须是 z.object()。这一步把同一份输入契约同时用于:
- TypeScript 静态类型;
- 运行时参数验证;
- OpenAI Function Calling 工具声明。
项目还提供两类组合器:ParallelToolExecutor 用固定并发 worker 执行互不依赖的工具任务,并保持结果顺序;ToolChain 把前一步输出写入上下文,再构造下一步输入,适合确定性流水线。
内置适配器包括计算、混合搜索、Memory、RAG、Notes 和协议工具。值得注意的是,通用 MemoryTool、RagTool 功能很全,包含写入和删除;后端业务并没有直接暴露它们,而是重新定义两个只读工具。这说明"库具备某个能力"不等于"业务 Agent 应拥有该权限"。
2.3 六种 Agent 范式的核心代码与差异
SimpleAgent:最小问答与文本工具调用
SimpleAgent 有两种工作方式:没有工具时只调用一次 LLM;有工具时在系统提示词中加入 [TOOL_CALL:工具名:{JSON}] 协议,再用正则解析模型输出。
完整循环是:
text
用户输入
-> LLM 输出文本
-> 是否包含 TOOL_CALL?
-> 否:保存历史并返回
-> 是:校验并执行工具
-> 将结果作为新 user 消息回填
-> 再次调用 LLM
它适合不支持原生 Function Calling 的兼容模型,但文本协议的稳定性依赖模型是否严格遵守格式。
FunctionCallAgent:原生工具调用闭环
FunctionCallAgent 是本项目后端实际使用的 Agent。它依赖 NativeToolCallingLlmClient,保留 SDK 的 assistant.tool_calls 和 tool_call_id:
ts
const completion = await this.nativeLlm.createToolCompletion({
messages,
tools,
toolChoice: this.defaultToolChoice,
temperature: this.config.temperature,
});
// assistant 消息必须先回填
messages.push({ role: "assistant", content, tool_calls: functionCalls });
// 每个工具结果再通过 tool_call_id 与原调用关联
messages.push({
role: "tool",
tool_call_id: call.id,
content: result,
});
如果模型不再返回工具调用,当前 assistant 文本就是最终答案。若达到 maxToolIterations,Agent 会使用 toolChoice: "none" 强制模型根据已有 Observation 收尾,防止无限循环。
ReActAgent:Thought、Action、Observation 循环
ReAct 每轮要求模型只输出一个判别联合 JSON:
{ type: "tool", thought, tool, input }:继续调用工具;{ type: "finish", thought, answer }:结束任务。
原始文本先经过 parseJson() 和 Zod 校验。格式错误不会立刻终止,而是作为一条 Observation 放入轨迹,下一轮让模型自我修正。工具执行结果同样追加到历史轨迹,直至完成或达到 maxSteps。
PlanAndSolveAgent:规划与执行解耦
这个 Agent 内部有两个角色:
Planner以温度 0 生成结构化步骤数组;Executor顺序执行每个步骤,并把之前步骤的结果传给下一步。
最后一步的结果作为最终答案,完整计划和执行历史写入 assistant 消息的 metadata。它适合能够提前拆解、步骤依赖明确的任务;如果任务必须根据外部观察动态改变计划,ReAct 会更自然。
ReflectionAgent:初稿---评审---改写
Reflection 的每次 run() 都创建独立的 ShortTermMemory,避免连续任务互相污染。流程为:
text
生成初稿
-> 评审模型输出 { needsImprovement, feedback }
-> needsImprovement=false:直接结束
-> needsImprovement=true:结合反馈和历史轨迹改写
-> 进入下一轮评审
它更适合文案、方案和代码审阅等"答案已经存在,但质量需要迭代"的任务。
ContextAwareAgent:先建上下文,再生成答案
ContextAwareAgent 本身很薄。它把用户问题、系统指令和会话历史交给 ContextBuilder.build(),将结果作为新的 system message,再调用一次 LLM。复杂性被下沉到可独立测试的上下文工程模块,而不是塞进 Agent 类。
六种范式如何选择
| 范式 | 决策特征 | 更适合 |
|---|---|---|
| Simple | 单次生成或文本工具协议 | 简单问答、兼容旧模型 |
| Function Calling | 模型原生结构化工具调用 | 生产型工具 Agent |
| ReAct | 根据 Observation 动态决定下一步 | 探索、检索、逐步计算 |
| Plan-and-Solve | 先得到完整计划再顺序执行 | 稳定的多步骤任务 |
| Reflection | 对已有答案反复评审优化 | 写作、方案、质量改进 |
| Context-Aware | 先选择和组织上下文 | 长期任务、记忆与知识融合 |
2.4 Memory:四类记忆如何统一管理
MemoryManager 是外部唯一入口,内部以 Map<MemoryType, BaseMemory> 注册四类实现:
- Working Memory:进程内短期状态,有 TTL 和容量限制。
- Episodic Memory:具体经历和交互事件,融合向量相似度、新近度与重要度。
- Semantic Memory:事实、概念和规则,融合向量召回与图关系召回。
- Perceptual Memory:文本、图像、音频、视频等模态记录,可按模态过滤。
新增记忆时,MemoryManager.addMemory() 会完成以下工作:
- Zod 校验输入;
- 根据显式类型或关键词自动分类;
- 根据内容长度、关键词和 metadata 计算重要度;
- 生成 UUID、时间和用户信息;
- 分派给具体
BaseMemory实现。
检索时则并行查询目标记忆类型,每类先多取候选,再按 ID 去重、全局排序和截断。forgetMemories() 支持按重要度、时间或容量遗忘;consolidateMemories() 可以把高价值短期记忆迁移为长期记忆,并在源删除失败时回滚目标写入。
内存版与生产版
createInMemoryMemoryManager() 使用 Map、哈希 Embedding 和内存图存储,适合单元测试与演示。
createProductionMemoryManager() 则完成如下装配:
text
Working -> 进程内 Map
Episodic -> SQLite + Qdrant
Semantic -> SQLite + Qdrant + Neo4j
Perceptual -> SQLite + Qdrant
SQLite 保存完整 MemoryItem,是权威来源;Qdrant 保存向量和检索过滤字段;Neo4j 保存语义实体与关系。StoredMemory.storeItem() 先写 SQLite,再生成向量并写 Qdrant;向量写入失败时删除刚写入的文档,避免留下半条记忆。更新失败时会尽量恢复旧文档和旧向量。
跨三个存储不可能依靠一个本地事务完全解决,因此项目还实现了两层一致性机制:
MemoryConsistencyScanner对比 SQLite、Qdrant 和 Neo4j 的 ID 集合,发现缺失向量、孤立向量和孤立图关系。SqliteMemoryOutbox将修复动作持久化,MemoryOutboxWorker负责幂等执行、失败重试和死信处理。
这部分是我与 Codex 在实现过程中从"教学示例"继续走向"工程实现"的关键:不仅要考虑正常写入,还要考虑外部服务部分失败、进程中断和稍后修复。
2.5 RAG:从原始文档到带引用回答
RAG 模块可以拆成五层:
text
DocumentLoader
-> MarkdownSplitter
-> RagIngestionPipeline
-> RagRetriever
-> RagService.ask()
文档加载与切分
LocalDocumentLoader 只允许读取配置根目录内的 Markdown、TXT、JSON 和 CSV,先通过 realpath 消除符号链接影响,再检查目标是否仍位于允许目录中。文本输入则直接转换为 LoadedRagDocument,并根据 namespace 与 source 生成稳定文档 ID。
MarkdownSplitter 不是简单按字符切割。它会维护 Markdown 标题栈,记录 headingPath、原文字符偏移和近似 Token 数;超大段落尽量在标点处断开;相邻 chunk 按 Token 数保留重叠;用于 Embedding 的文本还会清理 Markdown 标记。最终 chunk ID 由文档 ID、序号和内容哈希共同生成。
摄取与幂等更新
RagIngestionPipeline 先比较 contentHash 与 indexFingerprint。文档内容、Embedding 配置、切分参数都没变化时,直接复用已有索引。
发生变化时,流程如下:
- 切分新 chunk;
- 批量生成 Embedding;
- 计算新增 ID 和过期 ID;
- 先写入新向量;
- 在 SQLite 事务中替换文档和 chunk;
- SQLite 成功后清理过期向量。
SQLite 提交失败时,只删除这次新引入的向量,不能误删新旧版本共用的 chunk ID。
检索与回答
普通检索对原始问题生成向量;高级检索还可以启用:
- MQE:让 LLM 生成多个语义等价或互补查询;
- HyDE:让 LLM 先生成一段假设答案,用它参与向量检索。
多个查询并发检索后,RagRetriever 按 chunk ID 去重并保留最高得分,再从 SQLite 补全文本和权威元数据。MQE 或 HyDE 生成失败时会退回已经可用的查询,不让高级检索整体失效。
buildRagContext() 在字符预算内生成 [S1]、[S2] 来源块及结构化 citation;RagService.ask() 明确要求模型只根据资料作答、资料不足时说明,并把资料中的指令视为不可信数据。这既提供引用,也构成最基本的提示注入防线。
2.6 Context、Notes 与 Protocols
ContextBuilder 实现 GSSC 流程:
- Gather:收集系统指令、任务状态、相关记忆、RAG、最近对话和额外数据包;
- Select:按相关度与指数衰减的新近度评分,在 Token 预算中选择;
- Structure:组织成
[Role & Policies]、[Task]、[State]、[Evidence]、[Context]、[Output]; - Compress:超过预算时按区块截断,并为输出预留 Token。
FileNoteStore 用 Markdown 文件保存结构化笔记,并维护索引。写入时先创建同目录临时文件,再原子重命名;启动时可以扫描文件重建索引。笔记还能转换为 ContextPacket,参与上面的上下文选择。
协议层的职责分别是:
McpClient:连接 MCP Server,访问工具、资源和提示模板;A2AClient/A2AServer:暴露 Agent Card、技能列表和远程技能执行;ANPDiscovery/ANPNetwork:服务注册、能力筛选、最小负载选择和图路由。
createMcpTool()、createA2ATool()、createANPTool() 再把这些能力包装成普通 Tool。对 Agent 来说,本地函数、MCP 工具和远端 Agent 最终都通过相同的工具边界调用。
2.7 agent-patterns-ts 的主要实现流程
把我与 Codex 协作完成的上述模块串起来,一个典型的底层库使用流程是:
- 创建
HelloAgentsLlm,隔离具体模型服务。 - 创建
ToolRegistry,按业务最小权限注册工具。 - 如需知识库,创建生产 RAG,完成 SQLite、Qdrant、Embedding、Loader、Splitter、Pipeline、Retriever 的装配。
- 如需长期记忆,创建生产 Memory,完成四类记忆及 SQLite、Qdrant、Neo4j 的装配。
- 根据任务选择
FunctionCallAgent、ReAct、Plan-and-Solve、Reflection 或 Context-Aware。 - 调用
agent.run();Agent 负责 LLM 与工具之间的循环,业务层只接收{ answer, steps }。 - 程序结束时关闭 SQLite 与 Neo4j;后台可定期运行一致性扫描和 Outbox 修复。
也就是说,底层库提供的是可组合能力,不规定最终产品长什么样。第二个项目会把这些积木组装成具体的文档学习助手。
三、子项目二:document-qa-assistant------文档问答后端
document-qa-assistant 是在 Codex 协助下完成的 Fastify 应用,默认监听 http://127.0.0.1:3000。在这一部分的实现中,我与 Codex 共同把底层 Agent、RAG 和 Memory 能力装配为具体业务,并补充了 PDF 安全校验、错误映射、资源关闭和路由测试。它通过本地 tarball 依赖 @ericstone/agent-patterns-ts,说明后端消费的是底层库的公开 npm 导出,而不是越过边界直接引用源码。
text
document-qa-assistant/
├── src/
│ ├── app/ # 组合根、领域服务、Agent 工具、报告
│ ├── commands/ # 环境、PDF、运行时验证
│ ├── config/ # 环境变量校验
│ ├── documents/ # PDF 转换
│ ├── http/ # API 错误
│ ├── routes/ # 健康检查与业务接口
│ └── index.ts # 进程入口
├── tests/
├── uploads/ # 临时上传目录
├── knowledge/ # RAG 本地文件边界
├── data/ # SQLite 数据
└── reports/ # 可选报告文件
3.1 配置层:在启动前拒绝错误环境
loadAppConfig() 使用 Zod 分别读取 HTTP、身份、文件、LLM、Memory 和 RAG 配置。相对路径统一解析到后端项目目录,避免从不同工作目录启动时把数据库写到意外位置。
配置层还承担安全责任:
- 用户 ID 和 namespace 前缀限制字符集合;
- 上传大小、页数、最少文本字符数必须为正数;
- LLM Base URL 必须是合法 URL;
- Memory 与 RAG collection 不能同名;
toSafeConfigSummary()只输出无密钥配置,日志不会打印 API Key 和密码。
3.2 assistant-runtime.ts:整个后端的组合根
createAssistantRuntime() 是理解后端最重要的入口。它把所有基础设施组装在一起:
ts
const pdfConverter = await PdfDocumentConverter.create(...);
const reportWriter = await JsonLearningReportWriter.create(...);
const llm = new HelloAgentsLlm(...);
const rag = await createProductionRag(config.rag, llm);
const memory = await createProductionMemoryManager(...);
const tools = createAssistantToolRegistry(...);
const agent = new FunctionCallAgent(...);
const assistant = new DocumentQaAssistant(...);
初始化顺序有明确依赖:先创建本地文件组件和 LLM,再连接 RAG、Memory 外部设施,最后创建工具、Agent 和领域服务。中途失败时,已经创建的资源会按逆序关闭。返回对象的 close() 使用同一个 Promise,确保信号处理和异常处理重复调用时仍然幂等。
为什么后端只注册两个工具
createAssistantToolRegistry() 只提供:
knowledge_search:固定当前用户 namespace,只读搜索 RAG;memory_search:只读搜索当前用户 Memory。
它没有注册底层库的默认计算器、联网搜索,也没有把通用 RAG/Memory 工具的写入、删除、清空能力交给模型。真正的笔记写入由确定性的 HTTP 接口处理。这种最小权限设计可以降低模型误操作风险,也让审计边界更清楚。
3.3 DocumentQaAssistant:业务编排核心
DocumentQaAssistant 不关心 Fastify,也不直接创建数据库。它通过小型 Pick<> 接口依赖 PDF Converter、RAG、Memory、Agent 和 Report Writer,因此测试可以为每个依赖传入 mock。
它维护三类会话状态:
currentDocument:当前精确问答所针对的文档;metrics:加载、提问、笔记和 Agent 交互次数;sessionId/sessionStartedAt:报告与 Memory metadata 使用的会话标识。
其中一个容易忽略的细节是 agentQueue。FunctionCallAgent 自身保存会话历史,如果两个 /chat 请求并发运行,它们可能交叉修改消息。runAgentExclusive() 用 Promise 链串行执行 Agent 对话,同时允许 PDF 问答、统计等无关操作保持并发。
另外,RAG 是主业务,Memory 是辅助能力。加载文档或问答已经成功后,如果记忆写入失败,系统不会回滚 RAG,而是把错误放进 warnings 返回。这种处理比"任何辅助故障都让主请求失败"更符合用户体验。
3.4 PDF 转换器:上传文件进入知识库前的安全门
PdfDocumentConverter 负责的不只是提取文字。它依次完成:
realpath解析真实路径并限制在UPLOAD_ROOT;- 检查普通文件、
.pdf扩展名和文件大小; - 检查前五个字节是否为
%PDF-; - 使用
pdfjs-dist加载,并拒绝加密文件和超页数文件; - 顺序读取每一页,避免大 PDF 同时占用过多内存;
- 根据文字坐标和
hasEOL恢复行,处理中英文间距; - 文本过少时提示可能是扫描件或空文档;
- 生成带
# 标题、## 第 N 页的 Markdown。
页标题会自然进入 MarkdownSplitter 的 headingPath,所以最终 citation 能告诉用户答案来自第几页。这是 PDF 转换和 RAG 引用之间的重要连接。
3.5 路由层:传输校验、错误模型和临时文件清理
后端共开放八个接口:
| 方法 | 路径 | 作用 |
|---|---|---|
GET |
/api/health |
健康检查 |
POST |
/api/documents/pdf |
上传、解析并摄取 PDF |
POST |
/api/questions |
当前文档或知识库 RAG 问答 |
POST |
/api/chat |
与工具型学习 Agent 对话 |
POST |
/api/notes |
写入语义学习笔记 |
POST |
/api/memories/search |
检索学习记忆 |
GET |
/api/stats |
获取会话、RAG 和 Memory 统计 |
POST |
/api/reports |
生成学习报告 |
JSON 请求使用 .strict() Zod Schema,既限制长度与数值范围,也拒绝未声明字段。上传接口则限制一个文件、零普通字段、一个 part 和最大字节数。
文件流写入 uploads/<uuid>/安全文件名.pdf。无论转换成功还是失败,finally 都会递归清理这一个明确的临时目录;清理完成后才发送成功响应。
错误响应保持统一:
json
{
"success": false,
"error": {
"code": "NO_DOCUMENT_LOADED",
"message": "请先上传并加载 PDF 文档",
"requestId": "req-1"
}
}
已知校验和文档错误映射为 4xx;未知异常记录服务端日志,只向客户端返回通用 500 文案,避免泄漏内部路径和堆栈。
3.6 后端主要实现流程一:应用启动与关闭
下面几条后端流程均来自当前代码的实际实现;相关组合根、业务编排和异常分支是在 Codex 协助下逐步完成并通过测试校验的。
src/index.ts通过dotenv/config加载环境变量。loadAppConfig()校验配置并生成无密钥日志摘要。createApp()创建 Fastify,先注册/api/health。createAssistantRuntime()初始化 PDF、报告、LLM、RAG、Memory、Tools、Agent 和领域服务。registerAssistantApi()在/api作用域注册 multipart 与业务路由。- Fastify 开始监听。
- 收到
SIGINT或SIGTERM时,先停止 HTTP,再关闭 Memory 和 RAG 资源。
3.7 后端主要实现流程二:PDF 上传到可检索知识库
只有 RAG 摄取成功后,currentDocument 和 documentsLoaded 才会更新。记忆写入失败只产生 warning,不会让已经可问答的文档变成失败。
3.8 后端主要实现流程三:文档提问
/api/questions校验问题、范围和高级检索选项。- 未加载文档时返回
409 NO_DOCUMENT_LOADED。 DocumentQaAssistant.ask()先尝试写入 working memory,表示问题正在发生。- 默认启用 MQE 与 HyDE,并把
documentId传给 RAG,限定当前文档。 - Qdrant 召回候选,SQLite 补全文本与元数据。
RagService.ask()构造带[S1]标记的上下文并调用 LLM。- 成功后增加问题计数,并把"问题 + 截断回答"写为 episodic memory。
- 返回答案、结构化 citations 和辅助 warning。
如果 scope 改成 knowledge_base,就不附加 documentId 过滤,可以跨当前 namespace 中的所有文档检索。
3.9 后端主要实现流程四:Agent 对话
/api/chat 与 /api/questions 不是同一个入口:前者适合开放式学习、回顾和跨文档总结,后者适合当前文档的精确带引用问答。
Agent 对话流程为:
- 请求进入
runAgentExclusive()排队。 FunctionCallAgent携带系统提示词和会话历史调用模型。- 模型根据任务选择
knowledge_search或memory_search。 - 工具强制绑定当前 namespace/user,返回只读 JSON 数据。
- Agent 将
tool_call_id与结果回填给模型。 - 模型可能继续检索,也可能生成最终答案;最多 6 轮。
- 保存本轮 user/assistant 历史并增加 Agent 交互计数。
3.10 后端主要实现流程五:笔记、回忆、统计和报告
- 添加笔记:
/api/notes把内容以semantic、重要度0.8写入 Memory,并附加 concept、sessionId、documentId 等 metadata。 - 检索记忆:
/api/memories/search可筛选记忆类型、数量和最低重要度,最终由MemoryManager跨类型合并排序。 - 获取统计:
getStats()并发读取 RAG 与 Memory 统计,再合并进程内会话指标。 - 生成报告:并发读取统计和高价值记忆摘要,生成
schemaVersion: 1的结构化对象。 - 保存报告:需要持久化时,先写同目录临时文件,再原子重命名为
learning-report-<sessionId>.json。
后端至此把底层库变成了一个有明确权限、错误和数据生命周期的应用服务。
四、子项目三:document-qa-web------React 学习工作台
前端同样是在 Codex 协助下设计和实现的。它不是简单的接口测试页,而是把后端能力组织成五个连续学习阶段;我与 Codex 在实现过程中共同梳理了组件边界、请求契约、状态刷新、取消与超时处理,并补充了组件工作流和可访问性测试。项目使用 React 19 与 Vite,默认地址为 http://127.0.0.1:5173,开发服务器把 /api 代理到后端。
text
document-qa-web/src/
├── api/ # 契约、HTTP Client、业务 API、错误类型
├── components/ # 五个业务组件
├── config/ # 浏览器安全配置
├── styles/ # 全局样式与响应式布局
├── app.tsx # 页面和跨组件状态编排
└── main.tsx # React 挂载入口
4.1 API 契约:前端保持独立,又不放弃类型
前端没有直接从后端源码导入 TypeScript 类型,而是在 contracts.ts 中维护网络契约。这是有意的边界:前端可以独立安装和部署,HTTP 才是真正的跨项目接口。
AssistantApiContract 将每个"方法 + 路径"映射到 request/response 类型;assistantApiPaths 集中维护八个端点,避免组件散落硬编码 URL。
这个方案的代价是前后端类型需要同步维护。在更大的项目中,可以进一步使用 OpenAPI 生成客户端;在当前学习项目里,显式契约更便于观察每个字段的作用。
4.2 api-client.ts:统一处理成功、失败、超时和取消
通用客户端提供两个入口:
requestJson<T>():返回任意 JSON 结构,健康检查使用它;requestSuccess<T>():要求响应必须是{ success: true, data }。
请求时会同时组合外部 AbortSignal 和内部超时计时器,并把错误分类为:
| kind | 含义 |
|---|---|
api |
后端返回结构化业务错误或非 2xx |
network |
无法连接服务 |
timeout |
达到前端超时 |
aborted |
组件卸载或调用方主动取消 |
invalid_response |
响应为空、非 JSON 或结构不符 |
后端返回的 requestId 会进入 ApiClientError,组件展示错误时附带它,方便从浏览器问题追到服务端日志。
assistant-api.ts 再把底层请求转换为 uploadPdf()、askDocument()、chat()、addNote() 等业务方法。上传使用 FormData,故意不手动设置 Content-Type,让浏览器自动生成 multipart boundary;PDF 处理单独使用更长的超时。
4.3 App:只保留真正跨组件的状态
app.tsx 在模块级只创建一个 AssistantApi 实例。组件状态中只有两个全局协调值:
ts
const [currentDocument, setCurrentDocument] = useState<CurrentDocument>();
const [activityVersion, setActivityVersion] = useState(0);
currentDocument 决定问答区是否可用;activityVersion 是轻量的刷新信号。上传、提问、对话或笔记成功后递增它,LearningProgress 就会重新拉取统计。其余表单值、加载状态、错误和结果都留在各自组件中,避免顶层状态膨胀。
4.4 五个核心组件
DocumentUpload
前端先检查扩展名、MIME 和大小,校验通过后调用 uploadPdf()。这些检查只是用户体验优化,后端仍会复检。成功后展示标题、页数、切片数、处理时间和 warnings,并把 CurrentDocument 提升给 App。
DocumentQa
没有当前文档时,问题输入和按钮被禁用。提交时固定使用:
ts
{
question,
scope: "current_document",
useAdvancedSearch: true,
}
组件展示回答、引用来源、序号、相关度和 warnings。当 documentId 变化时会清除旧答案,避免用户误以为上一份文档的结果属于新文档。
LearningAssistant
这个组件内部有两个独立流程:Agent 聊天与笔记保存。两边各自维护 AbortController、loading 和 error,所以保存笔记不会阻塞聊天。聊天历史只存在当前 React 页面状态;真正需要长期保存的内容必须通过笔记接口进入后端 Memory。
LearningProgress
组件挂载和 refreshKey 变化时都会读取 /api/stats。它还提供独立的 Memory 检索表单,展示记忆内容、类型、综合分数和时间。统计加载与记忆搜索状态互不干扰;新统计请求发出前会取消旧请求,避免旧响应覆盖新状态。
LearningReport
用户可以选择记忆摘要条数,然后生成本次学习报告。前端固定传 saveToFile: false,因为服务器上的文件路径不是浏览器下载地址,也不应作为前端能力暴露。报告直接展示会话时长、活动次数、知识库规模、当前文档和记忆摘要。
4.5 前端主要实现流程一:启动与配置
以下前端流程是基于当前 React 源码的真实行为总结,相关组件和测试由我在 Codex 协助下完成。
- Vite 读取
VITE_API_BASE_URL、标题、上传上限和超时。 client-config.ts校验所有数值必须为正整数。main.tsx找到#root,在StrictMode中渲染App。App创建五个业务区域。LearningProgress首次请求统计,其余组件等待用户操作。
只有 VITE_ 前缀变量会进入浏览器代码,因此这里不能放 LLM、Embedding、Qdrant 或 Neo4j 密钥。
4.6 前端主要实现流程二:完整学习工作流
text
选择 PDF
-> 前端预校验
-> 上传并等待后端解析/索引
-> 保存 currentDocument
-> 启用当前文档问答
-> 展示答案和引用
-> 与 Agent 继续追问
-> 保存关键结论为语义笔记
-> 检索历史记忆
-> 生成本次学习报告
每次上传、问答、Agent 对话或笔记成功后,父组件都会刷新学习统计。这里没有引入 Redux 或复杂状态库,因为跨组件共享关系只有"当前文档"和"活动发生了"两件事,普通 React state 已经足够。
4.7 前端主要实现流程三:异步请求生命周期
每个异步组件都遵守相同模式:
- 提交前阻止重复操作;
- 创建新的
AbortController; - 清空旧错误并进入 loading;
- 调用业务 API;
aborted不显示为用户错误,其他错误统一转为可读文本;- 仅当 ref 仍指向当前 controller 时清理状态;
- 组件卸载时取消未完成请求。
这个细节可以避免组件卸载后更新状态,也能避免较早请求晚返回时破坏较新的 UI。
4.8 测试如何证明前端流程可用
前端使用 Vitest、jsdom 和 Testing Library。测试不只验证单个函数,还覆盖:
- API URL、JSON、超时、取消和错误转换;
- PDF 选择与上传状态;
- 问答、引用和文档切换;
- Agent 对话和笔记保存;
- 统计刷新、Memory 检索和报告;
- 从上传到报告的完整组件工作流;
- 跳到主内容链接、标题层级等可访问性结构。
测试从用户可见角色和标签查找元素,而不是依赖内部实现,因而组件重构后仍能验证真实交互行为。
五、如何在本地运行完整项目
5.1 环境准备
完整应用建议使用:
- Node.js 22.13 或更高版本;
- npm;
- Docker;
- 支持 Chat Completions 和原生 Function Calling 的 OpenAI-compatible LLM;
- OpenAI-compatible Embedding 服务;
- Qdrant 与 Neo4j。
三个项目要分别执行 npm ci。不要在根目录直接运行 npm 命令,因为仓库没有根 workspace。
5.2 启动 Qdrant 和 Neo4j
bash
cd agent-patterns-ts
docker compose -f docker-compose.memory.yml up -d
Compose 默认把数据写入 agent-patterns-ts/.data/。
5.3 配置并启动后端
bash
cd document-qa-assistant
npm ci
cp .env.example .env
编辑 .env,至少填写 LLM、Embedding 和 Neo4j 凭据,并确认两个 Qdrant collection 不同。然后执行:
bash
npm run verify:env
npm run verify:pdf # 可选:传入 PDF 路径验证转换
npm run verify:runtime
npm run dev
后端默认运行在 http://127.0.0.1:3000。
如果修改了 agent-patterns-ts,需要重新生成后端使用的 tarball:
bash
cd agent-patterns-ts
npm run verify:package
npm pack
然后更新或重新安装 document-qa-assistant/package.json 中指向该 tarball 的本地依赖。
5.4 启动前端
bash
cd document-qa-web
npm ci
cp .env.example .env.development
npm run dev
打开 http://127.0.0.1:5173,按页面 01 到 05 的顺序完成上传、问答、追问与笔记、进度查看和报告生成。
5.5 构建与测试
在每个改动涉及的子项目中运行:
bash
npm run typecheck
npm test
npm run build
后端和前端可以直接执行 npm run check。底层库还应执行:
bash
npm run verify:package
外部服务集成测试不会随普通 npm test 自动运行。配置好对应服务后,再在 agent-patterns-ts 中执行 npm run test:memory:integration 或 npm run test:rag:integration。
六、项目总结
这个项目是在 Codex 协助下完成的,这一点也是整个开发过程的重要组成部分。Codex 参与了代码分析、方案讨论、实现、测试和文档整理;我则把它作为学习与开发助手,围绕 Hello-Agents 的知识主线持续提出需求、理解实现并推进三个子项目落地。整个过程完成了两次递进式学习。
第一次是"理解 Agent"。通过在 Codex 辅助下使用 TypeScript 实现统一的 LLM、Message、Agent 和 Tool 契约,再完成 Simple、Function Calling、ReAct、Plan-and-Solve、Reflection 和 Context-Aware,可以真正看到 Agent 并不是一个神秘对象,而是围绕模型调用、状态、工具结果和终止条件组织起来的循环。
第二次是"把 Agent 做成应用"。仅有推理循环还不够,真实系统还需要:
- 用 Zod 同时约束模型参数、HTTP 输入和环境变量;
- 用 SQLite 保存权威数据,用 Qdrant 做语义召回,用 Neo4j 表达关系;
- 处理多存储部分失败、回滚、一致性扫描、Outbox 重试和死信;
- 限制文件路径、大小、签名、页数和文本质量;
- 为 Agent 设计最小权限工具,而不是开放所有底层能力;
- 区分"精确 RAG 问答"和"开放式 Agent 对话";
- 让前端正确处理超时、取消、错误追踪、异步竞争和可访问性。
三个子项目也形成了清晰的演进路径:
text
学习经典 Agent 范式
-> 抽象为可复用 TypeScript 库
-> 加入 Memory、RAG、Context 和协议
-> 通过 Fastify 组装业务与安全边界
-> 通过 React 呈现完整学习工作流
如果继续在 Codex 或其他开发工具的协助下扩展,最值得优先做的方向包括:用户认证与多租户隔离、OCR 扫描件支持、流式回答、OpenAPI 契约生成、RAG 重排、Memory 一致性定时任务、可观测性与评估体系。但在增加功能之前,当前项目最重要的价值已经建立起来:我借助 Codex,把 Hello-Agents 中的核心思想转化成了一套可以阅读、运行、测试和继续演进的 Node.js + TypeScript 全栈实现。
最后,再次感谢 Datawhale 和 Hello-Agents 的所有贡献者提供系统性的开源学习材料。转载或改编相关内容时,请保留来源,并遵循上游仓库标注的开源许可。