Chapter 05
综合:Agent 工具调度器
前四章分别给了概念、机制、实操、陷阱。这一章把它们拧成一个真实项目——一个 AI Agent 的工具调度层。重点不是"照步骤实现",而是判别:每个设计点,你该选第几章的哪个方案,为什么。
本章你将建立的 schema
- 把 01–04 的概念整合进一个端到端可运行的系统
- 在真实约束下做判别:catchTag vs catchAll、exit vs fail-fast、Layer vs 写死、timeout vs 手动取消
- 验收一个并发、超时、失败隔离、可中断、可测试的调度器
5.1项目背景
为一个 AI Agent 构建工具调度层。Agent 在一轮里决定调用若干工具(search、weather、calculator……),每个工具是一次外部调用。调度层的要求:
- 有界并发:同时最多跑 N 个,别打爆下游 API。
- 单项超时:每个工具最多等 2 秒,超时即取消那一个。
- 失败隔离:一个工具失败或超时,不影响其他工具的结果汇总。
- 共享依赖:所有工具共用一个
HttpClient,测试时可换假实现。 - 可被上游中断:用户取消整轮请求时,所有在飞的工具干净停止、释放资源。
timeout + exit,共享一个 HttpClient。注意:上游的 interrupt(红)打到调度层,会沿结构化并发自动传到所有工具 fiber——这是 §2.5 的保证在系统层面的兑现。5.2设计任务:判别决策(不是步骤实现)
动手前先做四个判别。每个都在前几章见过两个方案,这个场景下哪个对?
| 决策点 | 备选 | 这个场景选哪个?为什么 |
|---|---|---|
| 错误处理 | 01.6 catchTag / 04.5 catchAll | catchTag/exit。要区分工具失败种类、不能静默吞错(P5) |
| 结果收集 | 02.4 Effect.all fail-fast / 02.2 exit 隔离 | 每项 exit。一个工具失败不该让整轮汇总失败(P10) |
| 依赖 | 写死 new HttpClient() / 01.7 Tag+Layer | Tag + Layer。测试换假实现、生产共享单例(§2.3) |
| 超时取消 | 02.5 表 AbortController / Effect.timeout | Effect.timeout。自动中断且 finalizer 必跑(§2.5) |
5.3自己实现
按下面的验收标准,自己写一版 runTools + 一个测试用 HttpClient Layer。先别看参考实现。
验收 checklist(可观测行为)
- 4 个工具、并发设 3:能观察到不是一拥而上(用日志打时间戳验证最多 3 个同时在飞)。
- 一个工具
Effect.never(永不返回):它在 2 秒被取消,其余工具正常返回结果。 - 一个工具
Effect.fail:该工具exit是Failure,其余是Success,整轮不抛。 - 测试用 Layer 与生产 Layer 可互换,
runTools一行不改。
5.4亲手画一张图
亲手画一张图
合上教程,在纸上画出你的调度器在运行那一刻的 fiber 树:父 fiber(runTools)下挂几个子 fiber(每个工具一个)。只画 4 个节点。画完回到图 5.1 对照——你画的图里,"上游中断"那根箭头指向父还是子?finalizer 在哪一层运行?(答案:指向父,靠结构化并发自动向下传到子;每个子的资源 finalizer 在各自被中断时运行。)
5.5参考实现
参考实现(写完自己版本再展开)
dispatcher.tsTypeScript
import { Effect, Context, Layer, Data, Exit, Console } from "effect"
// ── 错误(01.6)──
class ToolError extends Data.TaggedError("ToolError")<{
readonly tool: string
readonly reason: string
}> {}
// ── 共享服务(01.7 + 02.3)──
class HttpClient extends Context.Tag("app/HttpClient")<
HttpClient,
{ readonly get: (url: string) => Effect.Effect<string, ToolError> }
>() {}
interface Tool {
readonly name: string
readonly call: Effect.Effect<string, ToolError, HttpClient>
}
const mkTool = (name: string, url: string): Tool => ({
name,
call: Effect.gen(function* () {
const http = yield* HttpClient
return yield* http.get(url)
}),
})
// ── 调度:有界并发(02.4) + 单项超时(02.5) + 失败隔离(02.2)──
const runTools = (tools: ReadonlyArray<Tool>) =>
Effect.all(
tools.map((t) =>
t.call.pipe(
Effect.timeout("2 seconds"),
Effect.exit,
Effect.map((exit) => ({ name: t.name, exit })),
),
),
{ concurrency: 3 },
)
// ── 测试用实现:换成生产实现只改这一个 Layer ──
const HttpClientTest = Layer.succeed(HttpClient, {
get: (url) =>
url.includes("slow")
? Effect.never // 模拟超时
: url.includes("boom")
? Effect.fail(new ToolError({ tool: url, reason: "boom" }))
: Effect.succeed(`result of ${url}`),
})
const program = runTools([
mkTool("search", "search?q=effect"),
mkTool("weather", "weather/tokyo"),
mkTool("slow", "slow-endpoint"),
mkTool("boom", "boom-endpoint"),
]).pipe(
Effect.tap((rows) =>
Effect.forEach(rows, (r) =>
Console.log(
`${r.name}: ${Exit.isSuccess(r.exit) ? r.exit.value : "FAILED/TIMEOUT"}`,
),
),
),
Effect.provide(HttpClientTest), // R: HttpClient → never
)
Effect.runPromise(program)
// search: result of search?q=effect
// weather: result of weather/tokyo
// slow: FAILED/TIMEOUT (2 秒被中断,没卡死)
// boom: FAILED/TIMEOUT (fail 被隔离在自己的 Exit)
关键决策说明:① Effect.exit 是失败隔离的核心——没有它,boom 的失败会让整个 Effect.all 短路。② timeout 让 slow(Effect.never)在 2 秒被中断,而不是永远挂起。③ 依赖只在最外层 provide 一次,业务代码(mkTool/runTools)只认 HttpClient 这个 Tag。④ 整个 program 仍是个值——上游想取消,对它 Fiber.interrupt 即可,中断沿结构化并发传到每个工具。
5.6反思问题
- 你的实现里哪个决策最难做?回去看是哪一章帮你定的?
- 如果需求变成"任一工具失败就整轮失败"(强一致),你要改哪一行?(提示:去掉
Effect.exit,让Effect.all恢复 fail-fast。) - 如果要给整轮加 5 秒总预算(不只是单项 2 秒),加在哪里?这和 03 章进阶挑战是同一个问题吗?
§本章 self-check
- 为什么
runTools的返回类型 R 是HttpClient而不是never?什么时候才变never? - 把并发从 3 改成
"unbounded",对"打爆下游"这条要求意味着什么?
答案
- 因为工具
call里yield* HttpClient,依赖被记进 R(02.3)。直到最外层Effect.provide(HttpClientTest)才把 R 消成never、可运行。 "unbounded"会让所有工具同时在飞,违背"有界并发别打爆下游"。具体数字(如 3)才是限流;这正是 P8 的教训。