P3:一个 Markdown 文件就是一个员工 ------ AgentScope-Java 多智能体编排实战(核心篇)
系列第三篇。P1 跑通了单个 Agent,P2 让它长出了双手(工具调用),这一篇解决真正的问题:一个 Agent 干不了的活,怎么让一队 Agent 分工干。
写作备注(发布前删除本节):
- 全文约 3400 字,5 处截图占位(【图1】~【图5】)
- 代码全部取自仓库真实实现:
agent/MultiAgentDemo.java、.agentscope/workspace/agents/*.md- 发布前填入 GitHub 仓库地址;【图4】注意脱敏(见文末检查项)
- 本文是全系列核心篇,篇幅最长,建议同步分发知乎/掘金时拆出「temperature 差异化」一节做独立小标题
你能获得什么
- 理解 AgentScope-Java 的多智能体编排模型:orchestrator-workers(总编-worker 模式)
- 学会用 一个 markdown 文件声明一个子 Agent------不改一行 Java 代码就能给团队加人
- 看懂一次完整的多 Agent 协作流程:派活 → 隔离执行 → 结果回传 → 整合简报
- 拿到一个可直接复用的「内容团队」模板:大纲、撰稿、审校三个角色,temperature 各不相同
【图1:文章封面/架构图】
一、为什么单个 Agent 不够用
P2 里我们的 Agent 已经会调工具了,但你很快会遇到三个天花板:
- 上下文污染。让一个 Agent 又查资料、又写初稿、又挑自己的错,它的上下文会被塞满中间过程,写到后半段质量明显下滑------它一边写一边被自己前面的草稿干扰。
- temperature 无法兼得。写初稿要发散(温度高一点才有创造力),审校要严谨(温度低一点才稳定)。一个 Agent 只有一个温度,鱼和熊掌不可兼得。
- 职责不可复用。审校逻辑写死在系统提示词里,下次做个「代码审查助手」还得重写一遍。
解法很经典:orchestrator-workers 模式。一个总编 Agent 负责理解需求、拆任务、派活;每个 worker 是独立的小 Agent,有自己的系统提示词、自己的温度、自己的上下文,干完活只回传结果。这和微服务里的「API 网关 + 微服务」是同一个思想------只不过这里的「服务」是会推理的。
AgentScope-Java 把这套模式做成了开箱即用的能力,而且声明一个 worker 只需要一个 markdown 文件。
二、核心机制:agents/ 目录就是团队花名册
先说结论:在 workspace 下的 agents/ 目录里放一个 markdown 文件,就等于雇了一个新员工。
文件分两段:YAML front matter 定义「人事信息」,正文就是系统提示词。以我们内容团队的 outliner(结构策划)为例,这是仓库里 .agentscope/workspace/agents/outliner.md 的原文:
markdown
---
name: outliner
description: 文章结构策划。把一个写作主题展开成结构化大纲,含开头钩子、核心论点、案例建议与结尾。需要列大纲时派给它。
temperature: 0.7
---
你是一个资深的公众号文章结构策划,擅长把一个主题拆解成有传播力的结构。
用户会给你一个写作主题,你需要输出一份可直接执行的大纲:
1. **开头钩子**:一句能让人停下来读下去的话(痛点提问 / 反常识结论 / 数据冲击,三选一)
2. **核心论点**:3 个,每个配一句展开说明
3. **案例与素材建议**:每个论点配什么代码、截图或数据
4. **结尾**:收束方式 + 下期预告钩子
要求:只输出大纲本身,不要写正文,不要多余解释。
front matter 里各字段的门道:
| 字段 | 作用 | 注意 |
|---|---|---|
name |
员工工号,总编派活时的「收件人」 | 必填,目录内唯一 |
description |
岗位 JD------总编就是靠它决定「这活该派给谁」 | 必填,且要写清「什么时候派给它」 |
temperature |
这个角色的发散程度 | 按职责定,见下文 |
tools |
该角色可用的工具名列表 | 按名引用父级 Toolkit 注册的工具,不写则没有工具 |
description 是最容易被低估的字段。总编自己不认识这些 worker,它选人完全依赖 description------写得含糊(比如只写「帮我干活」),总编就会乱派活。正确姿势是把 description 当 JD 写:职责 + 触发条件。上面 outliner 的描述里「需要列大纲时派给它」就是在给总编下路由规则。
再看 writer 的声明,注意它的 tools:
markdown
---
name: writer
description: 撰稿人。根据大纲或要点撰写公众号文章初稿,风格专业但易读,善用短段落和小标题。需要写初稿时派给它。
temperature: 0.8
tools:
- save_draft
---
你是一个技术公众号的撰稿人,文风专业但不枯燥:短段落、多小标题、关键处给代码或例子。
......
如果用户要求「保存」,调用 save_draft 工具把初稿存入草稿箱,标题取文章标题。
save_draft 是 P2 里我们用 @Tool 写的自定义工具(存在草稿箱)。父子工具打通的方式:在总编的 Toolkit 里注册一次,子 Agent 在声明里按名引用。也就是工具只写一遍,全团队共用。
三个角色的 temperature 各不相同,这是我们刻意的设计:
| 角色 | temperature | 理由 |
|---|---|---|
| outliner | 0.7 | 出大纲要点创意,但不能太飘 |
| writer | 0.8 | 写初稿最需要发散 |
| reviewer | 0.3 | 审校要稳定、可复现,温度高了会「看走眼」 |
这就是第二节说的「一个 Agent 无法兼得 temperature」的解法------把不同温度拆给不同角色。
【图2:agents/ 目录三个 md 文件(资源管理器 + 任一文件内容同框)】
三、总编怎么搭:一个 builder 搞定
worker 是 markdown 声明的,总编则在 Java 里构建。完整代码来自仓库的 MultiAgentDemo.java:
java
// 1. 父级 Toolkit:注册自定义工具,子 Agent 通过声明中的 tools 列表按名引用
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new DraftTools(Paths.get(".agentscope/workspace/drafts")));
// 2. 总编:workspace 下的 agents/ 目录即「团队花名册」
HarnessAgent editorInChief = HarnessAgent.builder()
.name("editor-in-chief")
.sysPrompt("""
你是一个公众号内容团队的总编,手下有三员干将:
- outliner:结构策划,负责出大纲
- writer:撰稿人,负责写初稿(可把成品存进草稿箱)
- reviewer:审校,负责挑错
工作流:接到需求后,先派 outliner 出大纲;大纲满意后派 writer \
写初稿;初稿完成后派 reviewer 质检。每一步必须等上一步结果回来再派下一棒。\
最终把各环节结论整合成一份简报回复用户。""")
.model(ModelFactory.build())
.workspace(Paths.get(".agentscope/workspace"))
.toolkit(toolkit)
.compaction(CompactionConfig.builder()
.triggerMessages(30)
.keepMessages(10)
.build())
.build();
四个关键点:
.workspace()指向的目录里如果存在agents/子目录,框架运行时会自动扫描加载,不需要手动注册任何子 Agent。这就是「声明式」的含义:加一个新员工 = 加一个 md 文件,Java 代码零改动。- 总编的 sysPrompt 里写工作流,而不是写每个环节怎么做------每个环节的专业知识在各自 worker 的提示词里。总编只管「谁先谁后、等上一步结果」。
.compaction()配了上下文压缩:超过 30 条消息时压缩只保留最近 10 条。整场编排下来事件很多,这个配置防止长会话把上下文撑爆(P4 会专门展开)。- 没有任何一行代码涉及 agent_spawn 等编排工具------框架默认给 HarnessAgent 自带了一整套编排工具 (
agent_spawn/agent_send/agent_list,连同文件读写、memory、session 系列共 21 个内置工具),总编开箱就会「派活」。
四、跑起来:一次完整的三棒接力
给总编发一个需求:
java
UserMessage message = new UserMessage(
"选题:《Java 开发者如何零 Python 基础搭建多智能体系统》。"
+ "请按团队流程走一遍:出大纲 → 写初稿 → 审校,最后给我一份执行简报。");
用 P1 讲过的 streamEvents() 监听事件流,把「派活」和「回传」的过程打印出来:
java
editorInChief.streamEvents(message, ctx)
.doOnNext(event -> {
switch (event.getType()) {
case TEXT_BLOCK_DELTA ->
System.out.print(((TextBlockDeltaEvent) event).getDelta());
case TOOL_CALL_START ->
System.out.println("\n[委派] " + ((ToolCallStartEvent) event).getToolCallName()
+ " ← 总编正在派活");
case TOOL_RESULT_END ->
System.out.println("\n[子Agent回传] " + ((ToolResultEndEvent) event).getToolCallName());
default -> { }
}
})
.blockLast();
运行命令:
bash
mvn -q exec:java -Dexec.mainClass="com.mk.agentscope.agent.MultiAgentDemo"
真实运行的控制台输出(节选)长这样:
text
=== 多智能体编排演示 ===
子 Agent 声明目录:.agentscope/workspace/agents/
[委派] agent_spawn ← 总编正在派活
[子Agent回传] agent_spawn:
一、开头钩子:「你的同事在用 Python 写 Agent,而你还在写 CRUD?」......
[委派] agent_spawn ← 总编正在派活
[子Agent回传] agent_spawn:
# 让 Agent 长出双手:......(初稿正文)
好,我用 save_draft 存一下草稿。
[委派] agent_spawn ← 总编正在派活
[子Agent回传] agent_spawn:
P0 必须改:第 2 节「Schema 自动生成」表述不严谨......
【执行简报】大纲已确认;初稿已存入草稿箱(drafts/p3-xxx.md);
审校提出 1 个 P0 问题、2 个 P1 建议,已附修改清单。
注意三个细节:
- 三次
[委派]都是agent_spawn------在总编眼里,派活就是一次普通的工具调用。多智能体编排没有引入任何新的执行模型,就是 ReAct 循环里多了一种「 spawning 子 Agent」的工具。这个设计非常干净。 - writer 回传里出现了
save_draft调用------子 Agent 在隔离上下文里自主干活,中途调用自己的工具,总编全程无感,只拿到最终结果。 - 最后的「执行简报」是总编自己整合的------它不转发原始长文,而是汇总各环节结论。这正是 orchestrator 的价值:信息向上收敛。
【图3:控制台完整输出截图(三棒接力的委派与回传)】 【图4:草稿箱目录截图------writer 真的通过 save_draft 落盘了初稿文件】
五、背后发生了什么
把这次运行拆开看,一次三棒接力的完整时序:
text
用户需求
│
▼
总编(editor-in-chief,ReAct 循环)
│ ① 推理:这活该 outliner 干 → 调 agent_spawn("outliner", 任务)
│ └─ outliner(temp 0.7,隔离上下文)→ 大纲文本回传
│ ② 推理:大纲 OK → 调 agent_spawn("writer", 大纲)
│ └─ writer(temp 0.8,隔离上下文)→ 初稿 + 调 save_draft 落盘
│ ③ 推理:初稿 OK → 调 agent_spawn("reviewer", 初稿)
│ └─ reviewer(temp 0.3,隔离上下文)→ 分级问题清单回传
│ ④ 推理:信息齐了 → 整合执行简报
▼
最终简报返回用户
几个值得说的机制:
- 隔离上下文:每个 worker 的对话发生在自己的上下文里,大纲讨论的 30 条消息不会进入 writer 的视野,writer 只拿到「大纲成品」。这就是解决「上下文污染」的方式。
- 运行期加载 :
agents/目录的扫描发生在首次推理时(由框架的中间件完成),而不是 builder 阶段。好处是声明文件可以随时增删,下一轮对话即刻生效------「团队花名册」是活的。 - 想验证声明文件写得对不对 ,不用起整个 Agent:
AgentSpecLoader.loadFromDirectory(agentsDir, workspace)可以单独调用,直接拿到解析后的声明对象做单测。CI 里加一条检查,markdown 写错了在构建期就暴露。
六、四条编排设计经验
- worker 的 description 按 JD 写:职责 + 触发条件。它是总编派活的唯一依据。
- 专业知识下沉到 worker,工作流上浮到总编。两边别写串:总编提示词里出现「大纲要包含钩子」就是越权。
- temperature 按职责分配 :创意型角色 0.7
0.9,质检型角色 0.10.3。这是多 Agent 相对单 Agent 最实在的收益之一。 - 控制回传体积:worker 结果别原样倾倒给总编,让 worker 自己先收敛(reviewer 输出分级清单而不是全文批注),或像 Demo 里那样对回传做截断预览。回传太长会快速吃掉总编的上下文。
七、踩坑记录
坑 1:静态声明目录是 agents/,不是 subagents/。 我们在搭建时先按直觉在 workspace 下建了 subagents/,死活不生效。翻框架源码常量(WorkspaceConstants.AGENTS_DIR = "agents")才发现:静态声明的目录名是 agents/ ,subagents/ 是运行期动态生成子 Agent 用的另一个机制。网上搜到的早期教程多写的是后者,注意区分。
坑 2:front matter 里 description 漏写不报错,但总编会乱派活。 声明文件解析对缺失字段相当宽容,漏了 description 也能加载成功。但总编路由全靠它------缺失后可能出现「该派 reviewer 的活派给了 writer」。建议用 AgentSpecLoader 写单测兜底。
坑 3:子 Agent 不会自动继承父级所有工具。 我们最初以为 Toolkit 注册的工具全团队默认可用,实际必须在子 Agent 声明的 tools 列表里按名引用 。writer 最初没配 tools,让它「存草稿」时它只能干瞪眼输出文本。权限最小化是设计使然,但第一次很容易踩。
八、下一篇预告
团队跑起来后,新的问题是持久与恢复 :会话能不能断点续传?上下文太长怎么办?记忆怎么分层沉淀?P4 我们把 JsonFileAgentStateStore 换成 Redis,聊聊 AgentScope-Java 的工程化底盘------会话持久化、上下文压缩与分层记忆。
仓库地址:github.com/525128633/a... 系列文章:P1 上手实测 · P2 工具调用实战 · P3 本篇