6 minutes
实战三:多 Agent 工作流搭建
第三个实战是把 13 章 Subagent 派发的理论变成一个可跑的多 Agent workflow:实情场景——“每周定期给团队新生 PR 做 triage / 分配 reviewer / 跑测试 brief”。
27.1 任务定义
每个周一早上 9 点,主 Agent 启动:
- 拉 open PRs(用 GitHub MCP)
- 按 PR 文件路径推理 owner(explore agent 5 个并行)
- 给每个 owner 生成 review brief(build agent, 5 个并行)
- 把 5 份 brief 拼成周报发给 Slack(slack MCP)
周一 9:00
Planner ─拉 PRs ──┐
│
5 explore ─并行─→ owner map
│
5 build ─并行─→ review brief
│
Slack send ─→ 单条最终消息
│
└ DONE 9:08
27.2 架构概览
┌── orchestrator (主 Agent / Sisyphus)
│ plan / dispatch / merge / notify
│
├── github MCP (pull github issues/PRs)
├── slack MCP (post messages)
├── filesystem MCP (read CODEOWNERS)
│
└── subagents:
├── 5x explore (每 PR 一个,找 files → owner)
└── 5x build (每 owner 一个, 写 review brief)
27.3 配 OpenCode plugin / skill 启动
把任务做成 project skill 启动事件:
.opencode/skills/weekly-triage/SKILL.md:
---
name: weekly-triage
version: 0.3.2
last_updated: 2026-07-23
---
# weekly-triage
## Trigger
User says "weekly triage" / "本周 triage" / "周一 review 一轮"
或 OpenCode cron mode: weekly/Mon/09:00
## Plan
1. Dispatch github.list_pull_requests to load this week's open PRs.
2. For each PR, dispatch a parallel explore subagent to:
- Read CODEOWNERS
- Read the diff stat files
- Output a single owner candidate: email + rationale
3. Dispatch parallel build subagents (1 per PR) to:
- Read that PR's full diff
- Produce a 6-line review brief: changes, risk, owner focus
4. Merge all briefs to single markdown.
5. Send via slack.post_message to #eng-triage channel
## DONE
- One slack message in #eng-triage with markdown body
## Failure modes
- No open PRs:
- Send 'no PRs this week' empty
- CODEOWNERS missing:
- Skip owner step, mark brief as `unknown`
- Slack rate limited:
- Retry once; if fails, save to artifact.md and notify milestone Hansen
27.4 Orchestrator 主代码
# scripts/weekly_triage.py
import asyncio, json
from mcp_client import MCPClient
mcp = MCPClient.connect("github", "slack", "filesystem")
async def weekly_triage():
prs_response = await mcp.call("github", "list_pull_requests",
{"state":"open","sort":"updated","direction":"desc"})
prs = parse(prs_response)["items"]
if not prs:
await post_slack("#eng-triage", "*No open PRs this week*")
return
# Stage 1: explore 5 个并发找 owner
owner_tasks = [dispatch_explore(pr) for pr in prs[:5]]
owners = await asyncio.gather(*owner_tasks)
# Stage 2: build 5 个并发写 brief
brief_tasks = [dispatch_build(pr, owner)
for pr, owner in zip(prs[:5], owners)]
briefs = await asyncio.gather(*brief_tasks)
# Stage 3: 合并 → slack
body = "\n\n---\n\n".join(
f"*PR #{pr['number']}: {pr['title']}*\nowner: {owner}\n{brief}"
for pr, owner, brief in zip(prs[:5], owners, briefs)
)
await mcp.call("slack", "post_message",
{"channel":"#eng-triage","text":body})
async def dispatch_explore(pr: dict) -> dict:
return await OpenCode_subagent(
subagent_type="explore",
prompt=f"""
[CONTEXT]
Got PR #{pr['number']} PR_title={pr['title']}
CODEOWNERS at .github/CODEOWNERS if exists.
[GOAL]
Return the actual owner for this PR: name + email
[REQUEST]
Read CODEOWNERS, identify matched owner given PR touched paths (= {pr['changed_files']})
Return JSON: {{owner: name, email: x@x, source: CODEOWNERS|git-blame|fallback:maintainer}}
[MUST NOT]
Do not modify any file.
风扇介绍.
""",
)
async def dispatch_build(pr: dict, owner: dict) -> str:
return await OpenCode_subagent(
subagent_type="build",
prompt=f"""
[CONTEXT]
PR #{pr['number']} {pr['title']} diff: {pr['diff_url']}
Owner: {owner['owner']} ({owner['email']})
[GOAL]
Write a 6-line review brief.
[REQUIRED TOOLS]
github.get_pull_request_files / read PR body / read tests
[OUTPUT]
Six lines max:
1. PR_title
2. Files changed (N total, K tests)
3. Risk area (string ≤ 30 chars)
4. Review focus (1 string)
5. Suggested reviewer priority / 5
6. Need extra reviewer? (yes/no)
[MUST DO]
Be terse. Avoid summary that the owner would already know.
[MUST NOT DO]
Do not propose patches. Do not review grading or opinionate.
""",
)
# 主入口
asyncio.run(weekly_triage())
27.5 五个细节
5.1 限并发,避免 rate limit
# 5 个 subagent 起码, 15 个会撞 github rate limit → 排队
# OpenCode 内置 `task` 也是 5 个并发
SEMAPHORE = asyncio.Semaphore(5)
async def dispatch_explore(pr: dict) -> dict:
async with SEMAPHORE:
return await OpenCode_subagent(...)
5.2 异常隔离
async def safe_gather(coro_factories, *args):
results = await asyncio.gather(*[c() for c in coro_factories],
return_exceptions=True)
return [r if not isinstance(r, Exception) else None for r in results]
一个 subagent crash 不连坐。
5.3 超时
try:
owners = await asyncio.wait_for(asyncio.gather(*owner_tasks), timeout=120)
except asyncio.TimeoutError:
owners = [None] * len(owner_tasks) # 后续根据 None 做降级
5.4 分享上下文 - 让 build subagent 拿到 explore 的结果
async def dispatch_build_with_owner(pr: dict, explore_result: str) -> str:
enriched_prompt = f"""
[CONTEXT]
Explore phase output:
{explore_result}
Now generate the brief.
"""
return await OpenCode_subagent(subagent_type="build", prompt=enriched_prompt)
不要每次 build 自己重新看 PR——拿 explore 结果给建。
5.5 加完成校验
async def validate_brief(text: str) -> bool:
if len(text.split("\n")) != 7:
return False # 6 lines + last line empty
if "PR_title" not in text[0:200]:
return False # 必须有
return True
# in main:
for pr, brief in zip(prs[:5], briefs):
if not await validate_brief(brief):
# retry once
brief = await dispatch_build(pr, ...)
# cache trace
27.6 五种失败模式
| 失败模式 | 现象 | 解法 |
|---|---|---|
| PR diff 数量过大 | model 跑超过 5 分钟不返回 | 切 PR diff summary mode |
| CODEOWNERS 缺 | explore 返回 None | build 时标 unknown |
| 只 PR author 自己 | triage 报错无 reviewer | 第 1 review 指 last commit author |
| Slack bot 无权 | message 复杂难解析 | 简化 message body,发纯文本 |
| 多语言 PR | prompt 内含中英混合 | Spec 中要求纯英文 brief,避免理解混乱 |
加这些进入 SKILL.md 的 <failure> section。
27.7 真实反馈循环:weekly triage周一跑起来
9:00 启动 weekly-triage skill (用户明说 CRON)
9:01 "拉了 5 个 PRs"
9:02 5 个 explore 同时跑
9:03 explore 阶段结束,4/5 成功(1 个 CODEOWNERS 不全)
9:03 5 个 build 同时跑
9:04 build 阶段结束,5/5 brief 都 6 行
9:05 slack 消息发出
9:08 完成,duration ≈ 8m, consumed 18k tokens
你早上 9 点半到办公室就看到简讯: 5 个 PRs 状态 + 4 个明确 owner 待 review + 1 PR 需要 closer-triage。
27.8 可观测性
加第 14 章的 trace - 每个阶段写入 trajectory:
from pathlib import Path
import json, time
def log_stage(stage: str, payload: dict):
out = Path(".triage") / f"{stage}.json"
out.write_text(json.dumps({"ts": time.time(), **payload}, ensure_ascii=False,
indent=2, default=str))
log_stage("start", {"prs_count": len(prs)})
log_stage("explore_done", {"owners": [o for o in owners]})
log_stage("build_done", {"briefs_size": [len(b) for b in briefs]})
log_stage("slack_sent", {"status": "ok"})
ls .triage/ 是一周 history,回看能识别"本季度最快的 5 PR triage → 6 分钟", “最慢的 → 35 分钟 (timeout)” 等经验。
27.9 三个性能脱ptime
| 优化 | 收获 | 写法 |
|---|---|---|
| 并行 explore | 墙钟 5x | asyncio.gather |
| 不读 huge diff | token 50% | PR diff URL 留 stdin,sub 用 github PR files API 取 file by file |
| cache CODEOWNERS | 每个 explore 都 read 一次 file → 5x IO | 主 agent read 一次,写到 explore prompt |
27.10 三层测试
| 测试层 | 做的事 | 工具 |
|---|---|---|
| unit | dispatch_explore, dispatch_build 的输出 schema | pytest + mock |
| integration | 用 fake GitHub API + fake slack MCP 验证 | mock server |
| e2e | 真实跑一次 + 截 slack msg | openCode cron + manual verify |
27.11 小结
- weekly-triage 是把第 13 章 Subagent theory 拼到 production 的样例
- 标准五件套: 并行 / 限制 / 异常 / 超时 / 完成校验
- 8 分钟成本换 30 分钟人时,每周省 22 分钟, 一年 19 小时
- 多 Agent 不等价"会变 OK",你要 supervisor 控制
下一篇: 《28 实战四:用 Graph 重构大型项目》——把 11 章 Graph 工作流用到新实际: 大型项目重构了几百里涉及的代码。
Summary: Weekly triage 30 min build, 8 min run;并发 5、异常隔离、超时、校验、Trajectory 五件套,token 换人时半年 ROI 突破 100h。