07. 综合项目:可恢复的研究报告 Agent#
前六篇分别介绍了 Agent harness、工具、上下文、规划、协作、持久化、安全、流式和评估。本篇把它们放进同一个项目:用户提交研究问题,协调者规划任务,研究员收集来源,证据写入工作区,报告员生成初稿,人工审批后才允许发布。
1. 先写工作流契约#
目标不是“生成一篇看起来合理的文章”,而是完成以下状态转换:
received
→ planned
→ researching
→ evidence_ready
→ draft_ready
→ waiting_for_approval
→ published | rejected | failed每个阶段有可检查证据:计划对应 todo,研究对应 evidence.md,初稿对应 draft.md,发布对应外部系统返回的 ID。模型可以提出下一步,但服务端和 backend 负责保存状态。
2. 项目结构#
research-agent/
├── src/
│ ├── model.ts # 模型初始化
│ ├── tools.ts # 搜索、发布和服务端校验
│ ├── agent.ts # 主 Agent 与子 Agent
│ ├── events.ts # 流式事件投影
│ └── index.ts # CLI 入口
├── skills/research/SKILL.md
├── AGENTS.md
└── package.json本项目可以先用本地 stub 跑通,再替换为搜索服务、持久 Store、审批 UI 和隔离 Sandbox。
3. 项目规则与 Skill#
AGENTS.md 只放始终适用的规则:
# Project rules
- 使用 TypeScript ESM,公共函数必须有测试。
- 外部来源必须保留 URL;没有来源时标记未核验。
- 不读取或写入 `.env`、凭据和用户 home。
- 发布、删除和发信必须等待人工审批。skills/research/SKILL.md 放研究流程:
---
name: research-report
description: 当用户要求基于来源撰写研究报告时使用;要求保存证据并标注不确定性。
---
# Research report
1. 先拆分检索问题。
2. 将原始结果和去重证据分开保存。
3. 每个关键断言至少保留一个来源。
4. 在最终报告中列出来源、限制和未核验项。4. 模型和工具#
// src/model.ts
import { ChatOpenAI } from "@langchain/openai";
export const model = new ChatOpenAI({
model: process.env.OPENAI_MODEL ?? "gpt-4.1-mini",
temperature: 0,
});工具层把搜索供应商和 Agent 隔离:
// src/tools.ts
import { tool } from "langchain";
import { z } from "zod";
export const searchDocs = tool(
async ({ query, maxResults }: { query: string; maxResults: number }) => {
// 演示 stub。真实应用在这里接入搜索服务、超时、配额和脱敏。
return JSON.stringify({
ok: true,
query,
results: [{
title: `关于 ${query} 的演示结果`,
url: "https://example.com",
snippet: "仅用于演示,请替换为真实来源。",
}].slice(0, maxResults),
});
},
{
name: "search_docs",
description: "搜索公开官方文档,返回标题、摘要和 URL。没有结果时必须明确返回空结果。",
schema: z.object({
query: z.string().min(3).max(200),
maxResults: z.number().int().min(1).max(8).default(5),
}),
},
);
export const publishReport = tool(
async ({ path, idempotencyKey }: { path: string; idempotencyKey: string }) => {
if (!path.startsWith("/report/")) {
return JSON.stringify({ ok: false, code: "INVALID_REPORT_PATH" });
}
// 生产服务必须用 idempotencyKey 去重,这里只返回演示结果。
return JSON.stringify({ ok: true, publishedPath: path, idempotencyKey });
},
{
name: "publish_report",
description: "发布已审批的报告。没有明确审批时不得调用。",
schema: z.object({
path: z.string().startsWith("/report/"),
idempotencyKey: z.string().min(8),
}),
},
);5. 主 Agent 和研究员#
// src/agent.ts
import {
createDeepAgent,
FilesystemBackend,
type SubAgent,
} from "deepagents";
import { todoListMiddleware } from "langchain";
import { MemorySaver } from "@langchain/langgraph";
import { model } from "./model.js";
import { publishReport, searchDocs } from "./tools.js";
const backend = new FilesystemBackend({
rootDir: process.env.AGENT_ROOT ?? "/absolute/path/to/research-agent",
virtualMode: true,
});
const researcher: SubAgent = {
name: "researcher",
description: "搜索和交叉核验官方来源,返回带 URL 的短小证据摘要。",
systemPrompt: `你是研究员。
把原始结果写入 /report/raw/,把去重后的证据写入 /report/evidence.md。
没有来源的断言放入 uncertainties,不要猜测。
不要修改项目代码,不执行外部副作用。`,
tools: [searchDocs],
};
export const agent = createDeepAgent({
model,
backend,
tools: [publishReport],
subagents: [researcher],
middleware: [todoListMiddleware()],
skills: ["/skills/"],
memory: ["/AGENTS.md"],
interruptOn: {
publish_report: { allowedDecisions: ["approve", "reject"] },
},
checkpointer: new MemorySaver(),
systemPrompt: `你是研究协调员。
复杂任务先用 write_todos 建立计划,再委派 researcher。
最终报告必须引用 /report/evidence.md。
在没有审批前,不得调用 publish_report。`,
});这里显式使用 FilesystemBackend,因为 AGENTS.md 和 skills/ 位于项目目录。若改用默认 StateBackend,必须把这些文件作为 FileData 传入 state,不能假定它会读取本地磁盘。
6. CLI 入口与恢复#
// src/index.ts
import { Command } from "@langchain/langgraph";
import { randomUUID } from "node:crypto";
import { agent } from "./agent.js";
const config = { configurable: { thread_id: randomUUID() } };
let result = await agent.invoke({
messages: [{ role: "user", content: "研究 deepagentsjs 的定位并生成报告。" }],
}, config);
if (result.__interrupt__) {
console.log("等待审批:", result.__interrupt__[0].value.actionRequests);
result = await agent.invoke(new Command({
resume: {
decisions: [{ type: "reject", message: "本次不发布,只保留草稿。" }],
},
}), config);
}
console.log(result.messages.at(-1)?.content);真实应用把 __interrupt__ 转换成审批卡片并保存;恢复时必须复用相同 thread_id。MemorySaver 只适合本地演示,生产需要持久化 checkpointer 和用户/租户隔离。
7. 一次运行如何检查#
不要只看最终文本,依次检查:
- 是否生成了合理的 todo;
- researcher 是否确实调用搜索;
/report/evidence.md是否存在且包含 URL;- 报告是否把未核验断言标记出来;
- 发布前是否进入 interrupt;
- reject 后是否没有发布调用;
- 恢复和重试是否不会重复副作用。
8. 生产化拆分#
服务化后建议拆成四个边界:运行服务负责调用 Agent,队列负责长任务生命周期,审批服务负责人工决策,backend/Store 负责数据。模型只提出下一步建议;权限服务负责最终裁决;观测系统负责回答“这次为什么和上次不同”。
综合验收清单
- 短问题不会无条件生成长计划。
- 原始搜索结果不会全部进入最终上下文。
- 子 Agent 只拥有完成任务所需的工具。
- evidence 文件缺失时不会生成确定性结论。
-
/report/之外的写入被拒绝。 - 发布会 interrupt,拒绝后没有外部发布。
- 断线恢复不会重复发布同一报告。
- 流式 UI 能区分主 Agent、子 Agent、工具和审批。
- trace 中有模型、依赖、thread 和评估版本。