Agent开发学习一:Hello-Agents TypeScript 全栈实现

在 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 的协作开发:

  1. agent-patterns-ts:可复用的 Agent 基础库。
  2. document-qa-assistant:组装 Agent、RAG、Memory 和 PDF 解析能力的 Fastify 后端。
  3. document-qa-web:覆盖上传、问答、对话、笔记、进度和报告的 React 前端。

这三个项目没有组成根目录 npm workspace,各自拥有独立的 package.json、依赖、测试和构建流程。这样的结构虽然需要分别安装依赖,却能清楚展示"基础库---业务后端---交互前端"三层之间的依赖方向。

一、整体项目架构

下面这套整体架构也是在 Codex 协助下逐步拆分和实现的。先从一次真实请求看全局:用户在浏览器上传 PDF,前端把文件交给 Fastify;后端解析文本并写入 RAG;提问时,检索器从 Qdrant 找候选片段,再从 SQLite 读取权威文本和元数据;LLM 基于检索上下文生成带引用回答;学习过程中的文档加载、问答和笔记又会进入 Memory,最终汇总成学习报告。

flowchart LR U[用户] --> WEB[document-qa-web<br/>React + Vite] WEB -->|HTTP /api| API[document-qa-assistant<br/>Fastify] API --> DQA[DocumentQaAssistant] DQA --> AGENT[FunctionCallAgent] DQA --> RAG[RagService] DQA --> MEMORY[MemoryManager] AGENT --> TOOLS[knowledge_search<br/>memory_search] TOOLS --> RAG TOOLS --> MEMORY AGENT --> LLM[OpenAI-compatible LLM] RAG --> SQLITE_R[(RAG SQLite)] RAG --> QDRANT_R[(RAG Qdrant Collection)] MEMORY --> SQLITE_M[(Memory SQLite)] MEMORY --> QDRANT_M[(Memory Qdrant Collection)] MEMORY --> NEO4J[(Neo4j)] LIB[agent-patterns-ts] -.提供核心能力.-> API

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.jsonexports 暴露根入口及 coreagentstoolsmemoryragcontextnotesprotocols 子路径。

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 校验 rolecontenttimestampmetadata,并冻结 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()。这一步把同一份输入契约同时用于:

  1. TypeScript 静态类型;
  2. 运行时参数验证;
  3. OpenAI Function Calling 工具声明。

项目还提供两类组合器:ParallelToolExecutor 用固定并发 worker 执行互不依赖的工具任务,并保持结果顺序;ToolChain 把前一步输出写入上下文,再构造下一步输入,适合确定性流水线。

内置适配器包括计算、混合搜索、Memory、RAG、Notes 和协议工具。值得注意的是,通用 MemoryToolRagTool 功能很全,包含写入和删除;后端业务并没有直接暴露它们,而是重新定义两个只读工具。这说明"库具备某个能力"不等于"业务 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_callstool_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 内部有两个角色:

  1. Planner 以温度 0 生成结构化步骤数组;
  2. 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() 会完成以下工作:

  1. Zod 校验输入;
  2. 根据显式类型或关键词自动分类;
  3. 根据内容长度、关键词和 metadata 计算重要度;
  4. 生成 UUID、时间和用户信息;
  5. 分派给具体 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;向量写入失败时删除刚写入的文档,避免留下半条记忆。更新失败时会尽量恢复旧文档和旧向量。

跨三个存储不可能依靠一个本地事务完全解决,因此项目还实现了两层一致性机制:

  1. MemoryConsistencyScanner 对比 SQLite、Qdrant 和 Neo4j 的 ID 集合,发现缺失向量、孤立向量和孤立图关系。
  2. 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 先比较 contentHashindexFingerprint。文档内容、Embedding 配置、切分参数都没变化时,直接复用已有索引。

发生变化时,流程如下:

  1. 切分新 chunk;
  2. 批量生成 Embedding;
  3. 计算新增 ID 和过期 ID;
  4. 先写入新向量;
  5. 在 SQLite 事务中替换文档和 chunk;
  6. 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 流程:

  1. Gather:收集系统指令、任务状态、相关记忆、RAG、最近对话和额外数据包;
  2. Select:按相关度与指数衰减的新近度评分,在 Token 预算中选择;
  3. Structure:组织成 [Role & Policies][Task][State][Evidence][Context][Output]
  4. 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 协作完成的上述模块串起来,一个典型的底层库使用流程是:

  1. 创建 HelloAgentsLlm,隔离具体模型服务。
  2. 创建 ToolRegistry,按业务最小权限注册工具。
  3. 如需知识库,创建生产 RAG,完成 SQLite、Qdrant、Embedding、Loader、Splitter、Pipeline、Retriever 的装配。
  4. 如需长期记忆,创建生产 Memory,完成四类记忆及 SQLite、Qdrant、Neo4j 的装配。
  5. 根据任务选择 FunctionCallAgent、ReAct、Plan-and-Solve、Reflection 或 Context-Aware。
  6. 调用 agent.run();Agent 负责 LLM 与工具之间的循环,业务层只接收 { answer, steps }
  7. 程序结束时关闭 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 使用的会话标识。

其中一个容易忽略的细节是 agentQueueFunctionCallAgent 自身保存会话历史,如果两个 /chat 请求并发运行,它们可能交叉修改消息。runAgentExclusive() 用 Promise 链串行执行 Agent 对话,同时允许 PDF 问答、统计等无关操作保持并发。

另外,RAG 是主业务,Memory 是辅助能力。加载文档或问答已经成功后,如果记忆写入失败,系统不会回滚 RAG,而是把错误放进 warnings 返回。这种处理比"任何辅助故障都让主请求失败"更符合用户体验。

3.4 PDF 转换器:上传文件进入知识库前的安全门

PdfDocumentConverter 负责的不只是提取文字。它依次完成:

  1. realpath 解析真实路径并限制在 UPLOAD_ROOT
  2. 检查普通文件、.pdf 扩展名和文件大小;
  3. 检查前五个字节是否为 %PDF-
  4. 使用 pdfjs-dist 加载,并拒绝加密文件和超页数文件;
  5. 顺序读取每一页,避免大 PDF 同时占用过多内存;
  6. 根据文字坐标和 hasEOL 恢复行,处理中英文间距;
  7. 文本过少时提示可能是扫描件或空文档;
  8. 生成带 # 标题## 第 N 页 的 Markdown。

页标题会自然进入 MarkdownSplitterheadingPath,所以最终 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 协助下逐步完成并通过测试校验的。

  1. src/index.ts 通过 dotenv/config 加载环境变量。
  2. loadAppConfig() 校验配置并生成无密钥日志摘要。
  3. createApp() 创建 Fastify,先注册 /api/health
  4. createAssistantRuntime() 初始化 PDF、报告、LLM、RAG、Memory、Tools、Agent 和领域服务。
  5. registerAssistantApi()/api 作用域注册 multipart 与业务路由。
  6. Fastify 开始监听。
  7. 收到 SIGINTSIGTERM 时,先停止 HTTP,再关闭 Memory 和 RAG 资源。

3.7 后端主要实现流程二:PDF 上传到可检索知识库

sequenceDiagram participant C as Web Client participant F as Fastify Route participant P as PdfDocumentConverter participant D as DocumentQaAssistant participant R as RAG Pipeline participant M as Memory C->>F: multipart file F->>F: 校验字段/MIME/大小并保存临时文件 F->>D: loadPdf(filePath) D->>P: convert(filePath) P-->>D: Markdown + 页数 + metadata D->>R: ingestText(markdown, namespace) R->>R: 切分、Embedding、SQLite/Qdrant 写入 R-->>D: documentId + chunkCount D->>M: 记录 document_loaded 情景记忆 D-->>F: 文档信息 + warnings F->>F: 删除临时目录 F-->>C: 201 Created

只有 RAG 摄取成功后,currentDocumentdocumentsLoaded 才会更新。记忆写入失败只产生 warning,不会让已经可问答的文档变成失败。

3.8 后端主要实现流程三:文档提问

  1. /api/questions 校验问题、范围和高级检索选项。
  2. 未加载文档时返回 409 NO_DOCUMENT_LOADED
  3. DocumentQaAssistant.ask() 先尝试写入 working memory,表示问题正在发生。
  4. 默认启用 MQE 与 HyDE,并把 documentId 传给 RAG,限定当前文档。
  5. Qdrant 召回候选,SQLite 补全文本与元数据。
  6. RagService.ask() 构造带 [S1] 标记的上下文并调用 LLM。
  7. 成功后增加问题计数,并把"问题 + 截断回答"写为 episodic memory。
  8. 返回答案、结构化 citations 和辅助 warning。

如果 scope 改成 knowledge_base,就不附加 documentId 过滤,可以跨当前 namespace 中的所有文档检索。

3.9 后端主要实现流程四:Agent 对话

/api/chat/api/questions 不是同一个入口:前者适合开放式学习、回顾和跨文档总结,后者适合当前文档的精确带引用问答。

Agent 对话流程为:

  1. 请求进入 runAgentExclusive() 排队。
  2. FunctionCallAgent 携带系统提示词和会话历史调用模型。
  3. 模型根据任务选择 knowledge_searchmemory_search
  4. 工具强制绑定当前 namespace/user,返回只读 JSON 数据。
  5. Agent 将 tool_call_id 与结果回填给模型。
  6. 模型可能继续检索,也可能生成最终答案;最多 6 轮。
  7. 保存本轮 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 协助下完成。

  1. Vite 读取 VITE_API_BASE_URL、标题、上传上限和超时。
  2. client-config.ts 校验所有数值必须为正整数。
  3. main.tsx 找到 #root,在 StrictMode 中渲染 App
  4. App 创建五个业务区域。
  5. LearningProgress 首次请求统计,其余组件等待用户操作。

只有 VITE_ 前缀变量会进入浏览器代码,因此这里不能放 LLM、Embedding、Qdrant 或 Neo4j 密钥。

4.6 前端主要实现流程二:完整学习工作流

text 复制代码
选择 PDF
  -> 前端预校验
  -> 上传并等待后端解析/索引
  -> 保存 currentDocument
  -> 启用当前文档问答
  -> 展示答案和引用
  -> 与 Agent 继续追问
  -> 保存关键结论为语义笔记
  -> 检索历史记忆
  -> 生成本次学习报告

每次上传、问答、Agent 对话或笔记成功后,父组件都会刷新学习统计。这里没有引入 Redux 或复杂状态库,因为跨组件共享关系只有"当前文档"和"活动发生了"两件事,普通 React state 已经足够。

4.7 前端主要实现流程三:异步请求生命周期

每个异步组件都遵守相同模式:

  1. 提交前阻止重复操作;
  2. 创建新的 AbortController
  3. 清空旧错误并进入 loading;
  4. 调用业务 API;
  5. aborted 不显示为用户错误,其他错误统一转为可读文本;
  6. 仅当 ref 仍指向当前 controller 时清理状态;
  7. 组件卸载时取消未完成请求。

这个细节可以避免组件卸载后更新状态,也能避免较早请求晚返回时破坏较新的 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:integrationnpm 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 的所有贡献者提供系统性的开源学习材料。转载或改编相关内容时,请保留来源,并遵循上游仓库标注的开源许可。

相关推荐
宋哥转AI1 小时前
深入理解 AI Agent · AGENT #03:从单 Agent 到多 Agent
人工智能·agent·ai编程
修远客1 小时前
对话式修改:Agent的人机协作模式 — Chat as Interface,对话不是聊天,是最高效的人机协作方式
llm·agent
武子康1 小时前
机器人接入 AI 连续语音后,端云架构要改什么?
人工智能·llm·agent
要有锋芒_不要疯忙1 小时前
Agent知识(二)----Prompt Engineer发展与未来
人工智能·agent
百工蜂Agent1 小时前
上下文压缩扔得掉对话,扔不掉你的 CLAUDE.md
agent·ai编程
樊小肆1 小时前
DeepSeeker-Code源码导读09-MCP集成
人工智能·agent
YIAN1 小时前
TS 面试必考题:type 与 interface 的 6 大核心区别,90% 的人答不全
前端·typescript
程序员柒叔1 小时前
luna 的内心独白:我把一个本该暂停的任务,跑成了几十轮空转
agent·ai编程·vibecoding
leeyi2 小时前
Deep Agent 文件系统工具链:ls/read/write/edit/glob/grep/shell 七个工具怎么设计(第94篇-E80)
aigc·agent·ai编程