07. 综合项目:可恢复的研究报告 Agent
8 min read

07. 综合项目:可恢复的研究报告 Agent

将规划、研究、证据写入、报告生成、人工审批和发布串成一条可恢复、可验证的研究报告 Agent 工作流。

07. 综合项目:可恢复的研究报告 Agent#

前六篇分别介绍了 Agent harness、工具、上下文、规划、协作、持久化、安全、流式和评估。本篇把它们放进同一个项目:用户提交研究问题,协调者规划任务,研究员收集来源,证据写入工作区,报告员生成初稿,人工审批后才允许发布。

1. 先写工作流契约#

目标不是“生成一篇看起来合理的文章”,而是完成以下状态转换:

text
received
  → planned
  → researching
  → evidence_ready
  → draft_ready
  → waiting_for_approval
  → published | rejected | failed

每个阶段有可检查证据:计划对应 todo,研究对应 evidence.md,初稿对应 draft.md,发布对应外部系统返回的 ID。模型可以提出下一步,但服务端和 backend 负责保存状态。

2. 项目结构#

text
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 只放始终适用的规则:

markdown
# Project rules

- 使用 TypeScript ESM,公共函数必须有测试。
- 外部来源必须保留 URL;没有来源时标记未核验。
- 不读取或写入 `.env`、凭据和用户 home。
- 发布、删除和发信必须等待人工审批。

skills/research/SKILL.md 放研究流程:

markdown
---
name: research-report
description: 当用户要求基于来源撰写研究报告时使用;要求保存证据并标注不确定性。
---

# Research report

1. 先拆分检索问题。
2. 将原始结果和去重证据分开保存。
3. 每个关键断言至少保留一个来源。
4. 在最终报告中列出来源、限制和未核验项。

4. 模型和工具#

typescript
// 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 隔离:

typescript
// 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 和研究员#

typescript
// 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.mdskills/ 位于项目目录。若改用默认 StateBackend,必须把这些文件作为 FileData 传入 state,不能假定它会读取本地磁盘。

6. CLI 入口与恢复#

typescript
// 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_idMemorySaver 只适合本地演示,生产需要持久化 checkpointer 和用户/租户隔离。

7. 一次运行如何检查#

不要只看最终文本,依次检查:

  1. 是否生成了合理的 todo;
  2. researcher 是否确实调用搜索;
  3. /report/evidence.md 是否存在且包含 URL;
  4. 报告是否把未核验断言标记出来;
  5. 发布前是否进入 interrupt;
  6. reject 后是否没有发布调用;
  7. 恢复和重试是否不会重复副作用。

8. 生产化拆分#

服务化后建议拆成四个边界:运行服务负责调用 Agent,队列负责长任务生命周期,审批服务负责人工决策,backend/Store 负责数据。模型只提出下一步建议;权限服务负责最终裁决;观测系统负责回答“这次为什么和上次不同”。

综合验收清单

  • 短问题不会无条件生成长计划。
  • 原始搜索结果不会全部进入最终上下文。
  • 子 Agent 只拥有完成任务所需的工具。
  • evidence 文件缺失时不会生成确定性结论。
  • /report/ 之外的写入被拒绝。
  • 发布会 interrupt,拒绝后没有外部发布。
  • 断线恢复不会重复发布同一报告。
  • 流式 UI 能区分主 Agent、子 Agent、工具和审批。
  • trace 中有模型、依赖、thread 和评估版本。

相关文章