创建日期:2026-09-08 | 最近更新:2026-09-08 本机真实运行(DeepSeek Anthropic 兼容端点,
deepseek-v4-flash);输出与产物文件均真实。运行时见篇 0。
Agent 入门 2:复杂 Agent——多工具、多步编排、以落盘为准
简单 Agent 只会「一问一答调工具」。真正的工作型 Agent 要把一个任务拆成好几步、每一步都真调工具、最后留下可验证的产物。这篇的 Agent 会:查时间 → 查两个城市人口 → 把结果写成 Markdown 文件 → 列目录确认 → 读回核对 → 给你总结。「完成」以文件真实落盘为准,而不是模型嘴上说写完了。
1. 它比简单 Agent「复杂」在哪
| 能力 | 简单 Agent | 复杂 Agent |
|---|---|---|
| 工具数 | 2 | 5(时间/人口/写文件/列目录/读文件) |
| 任务 | 一次问答 | 分 5 步的真实任务(要组织文件) |
| 状态 | 无 | 文件系统即记忆(写 report.md 后还能读回) |
| 完成判定 | 模型说不调工具 | 工具结果 + 落盘文件双重确认 |
| 健壮性 | 有最大轮数 | 最大轮数 + 单工具 try/catch 兜底 + 收窄到工作区 |
最核心的一条:它真的在「干活」——模型负责理解和提议,你的工具负责执行并把结果固化到磁盘;下次(甚至别的进程)都能通过 list_files/read_file 看到它做过什么。
2. 代码:complex.mjs
// 复杂 Agent:多工具 + 多步编排 + 文件工作区。
import { mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { complete, textOf, thinkOf, toolUses, userMsg, modelMsg, config } from './runtime.mjs';
const WORKSPACE = join(process.cwd(), 'workspace'); // 收窄:只允许写这个目录
mkdirSync(WORKSPACE, { recursive: true });
const CITY_DB = {
北京: '2189 万', 上海: '2487 万', 广州: '1881 万', 深圳: '1768 万',
杭州: '1252 万', 成都: '2140 万', 南京: '949 万', 武汉: '1373 万',
};
// 每个工具 = 输入 Schema + run()。文件工具相对 WORKSPACE 解析 → 天然“沙箱”。
const tools = [
{ name: 'get_current_time', description: '获取当前时间(上海时区)',
input_schema: { type: 'object', properties: {}, required: [] },
run: () => new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) },
{ name: 'city_population', description: '查询某城市人口(万人)',
input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
run: (a) => CITY_DB[a.city] ?? `无「${a.city}」数据` },
{ name: 'write_file', description: '写入/覆盖工作区里的一个文件(相对路径)',
input_schema: { type: 'object',
properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
run: (a) => { writeFileSync(join(WORKSPACE, a.path), a.content, 'utf8'); return `已写入 ${a.path}(${a.content.length} 字符)`; } },
{ name: 'list_files', description: '列出工作区文件',
input_schema: { type: 'object', properties: {}, required: [] },
run: () => readdirSync(WORKSPACE).join(', ') || '(空)' },
{ name: 'read_file', description: '读取工作区里一个文件的内容',
input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
run: (a) => readFileSync(join(WORKSPACE, a.path), 'utf8') },
];
// 系统提示:把“扮演”和“做”分开。用户任务在下面。
const SYS = '你是用工具完成真实任务的工作型 agent:能查时间/人口、能读写工作区文件。要“做到”,不要“假装做到”。';
const TASK = `你是一个“工作型 agent”,请在本会话里实际使用工具完成:
1) 用 get_current_time 获取当前时间;
2) 用 city_population 分别查询 上海 和 深圳 的人口;
3) 把上面内容整理成 Markdown 报告(含时间 + 两个城市人口的小表格),用 write_file 写入文件 report.md;
4) 用 list_files 确认 report.md 已生成;
5) 用 read_file 读回 report.md,然后给我不超过 2 行的完成总结。
注意:write_file 的 path 直接写 "report.md";不要编造工具结果,每步都真实调用。`;
async function run() {
console.log(`任务:${TASK.split('\n')[0]}…\n`);
const messages = [userMsg(TASK)];
for (let turn = 0; turn < 8; turn++) { // 保险丝
console.log(`— 第 ${turn + 1} 轮 —`);
const reply = await complete(messages, { tools, maxTokens: 2048, system: SYS });
const think = thinkOf(reply);
if (think) console.log(` [思考] ${think.split('\n')[0].slice(0, 90)}…`);
messages.push(modelMsg(reply));
const calls = toolUses(reply);
if (calls.length === 0) {
console.log(`完成:${textOf(reply)}\n`);
return;
}
// 同批工具结果一次性回填;单工具 try/catch,失败也回填 is_error 让模型补救
const results = calls.map((c) => {
const tool = tools.find((t) => t.name === c.name);
if (!tool) return { type: 'tool_result', tool_use_id: c.id, content: `没有 ${c.name}` };
try {
const out = tool.run(c.input ?? {});
console.log(` ▶ ${tool.name}(${JSON.stringify(c.input)}) → ${String(out).slice(0, 80)}`);
return { type: 'tool_result', tool_use_id: c.id, content: String(out) };
} catch (e) {
return { type: 'tool_result', tool_use_id: c.id, content: `工具出错: ${e.message}`, is_error: true };
}
});
messages.push({ role: 'user', content: results });
}
console.log('(达到最大轮数,结束)');
}
if (!config.hasKey) { console.error('缺少 ANTHROPIC_AUTH_TOKEN'); process.exit(1); }
await run();
3. 跑起来
node complex.mjs
4. 真实运行输出(本机实测,4 轮完成)
任务:你是一个“工作型 agent”,请在本会话里实际使用工具完成:…
— 第 1 轮 —
[思考] Let me start by calling the independent tools: get_current_time, and city_population for b…
▶ get_current_time({}) → 2026/9/8 16:04:32
▶ city_population({"city":"上海"}) → 2487 万
▶ city_population({"city":"深圳"}) → 1768 万
— 第 2 轮 —
▶ write_file({"path":"report.md","content":"# 城市人口报告\n…"}) → 已写入 report.md(125 字符)
— 第 3 轮 —
▶ list_files({}) → report.md
▶ read_file({"path":"report.md"}) → # 城市人口报告\n\n**生成时间**:2026/9/8 16:04:32\n… (内容节选)
— 第 4 轮 —
完成:已完成全部5步:查询到当前时间(2026/9/8 16:04:32)、上海2487万、深圳1768万,并写入 report.md,
经 list_files 和 read_file 双重确认文件内容无误。
产物确实落盘(宿主视角,不是模型自述):
$ cat workspace/report.md
# 城市人口报告
**生成时间**:2026/9/8 16:04:32
## 人口查询结果
| 城市 | 人口(万人) |
| --- | --- |
| 上海 | 2487 |
| 深圳 | 1768 |
> 数据来源:实时工具查询结果。
看整条链路:模型负责「决定下一步 + 组织内容」,宿主工具负责「查真实数据、把文件写进磁盘」。第 4 轮它自己用 read_file 把文件读回核对后才收尾——这就是「以工具结果/落盘为准」的自觉。
5. 让它更「复杂/更强」的旋钮(进阶方向)
| 想加的能力 | 怎么做 | 已有人做好(参考本站) |
|---|---|---|
| 更多真实工具(网络/浏览器/数据库) | 加工具;或接 MCP 让第三方提供工具 | MCP 系列 |
| 长期记忆 | 把「记忆」落成文件/向量库,每次任务先读 | 本 agent 的 workspace 就是最朴素的记忆 |
| 规划能力 | 让它先输出 plan 再执行,或加一个子 Agent 专职拆解 | (后续可单独成篇) |
| 不要手搓循环 | 用现成 agent 库(自带的循环更健壮) | pi-agent 系列 |
| 安全/护栏 | 工具白名单、工作区沙箱、重动作确认、审计 | 参考 InkOS 的确认闸门 |
为什么这套值得掌握:你从零手写过 tool_result 合并、thinking 过滤、轮数保险丝、工作区沙箱之后,再看任何 agent 框架/MCP/生产 harness,都能一眼看出「它在替你做什么循环、把安全边界放哪」。
6. 三个容易翻车的点
- 路径逃逸:文件工具必须把路径
join(WORKSPACE, …)收窄,否则模型能读/写任意路径(严重安全漏洞); - 假装完成:光看最终文本不保险,要「工具结果 + 落盘文件」双确认——这也是为什么任务里强制它
read_file核对; - 错误要回填成
is_error:工具抛错别吞掉,回填is_error: true的 tool_result,模型会自己改参数/换策略重试。
动手
- 把 TASK 改成「查询 3 个城市并生成一个 CSV」;看它会不会用多个 write_file / 自己规划;
- 给它一个「读取任意路径」的坏工具(不加 join 收窄),测试路径逃逸,体会沙箱的必要;
- 在 workspace 预放一个旧文件,看它
write_file前会不会先list_files/read_file(好的 agent 会先侦察)。
自测
- 复杂 agent 的「完成」以什么为准?为什么?
- 文件工具为什么要相对工作区收窄路径?
- 工具抛错时应回填成什么?对模型有什么意义?
- 哪一轮体现「模型自己在读回核对」?
- 想要「网络搜索」「查数据库」,下一步该加什么(MCP)?
参考
- Anthropic Messages API 工具调用(协议):platform/claude 工具使用
- 本站底层已成型:pi-agent 系列(把本篇循环做成了库)、MCP、InkOS 生产 harness
- 本系列代码位于 /tmp/frontend-agent(runtime.mjs / simple.mjs / complex.mjs),可复跑