Appearance
第6章 工具编排与并发执行
上一章我们讨论了如何设计好的工具。但设计出来的工具终归要被执行。一个 Agent 在一次对话中可能调用几十甚至上百次工具,如何编排这些调用——按什么顺序、是否并发、如何处理依赖关系、怎么控制资源消耗——这些问题直接决定了 Agent 的执行效率和可靠性。
本章的核心论点是:工具编排不是简单的"调用函数",而是一个调度系统的设计问题。 它和操作系统的进程调度、分布式系统的任务编排有着深层的结构相似性。
6.1 顺序执行:最朴素的模式
最简单的工具编排方式是顺序执行:模型返回一个工具调用请求,Harness 执行它,把结果喂回模型,模型再决定下一步。
typescript
// 最基本的顺序执行循环
async function agentLoop(messages: Message[]) {
while (true) {
const response = await llm.chat(messages);
if (response.stopReason === "end_turn") {
return response.content;
}
for (const toolCall of response.toolCalls) {
const result = await executeTool(toolCall);
messages.push({ role: "tool", content: result, toolCallId: toolCall.id });
}
}
}顺序执行的优势显而易见:简单、可预测、易调试。 每个工具调用的上下文是明确的,出了问题只需要看上一次调用的结果。对于大多数简单场景——比如"读一个文件,然后修改它"——顺序执行完全够用。
但它的代价也很明显:慢。 如果模型需要同时读取 5 个文件来理解一个模块的架构,顺序执行意味着 5 次串行的 I/O 等待。每次等待不仅包含磁盘读取时间,还包含一次完整的 LLM 推理——因为每读完一个文件,模型都需要重新决定下一步读哪个文件。这不仅慢,还浪费 token。
实际上,第 3 章讨论的 Agent Loop 在早期原型中几乎都采用顺序执行。LangChain 最早的 AgentExecutor 就是这个模式。它能跑,但在真实场景中,用户会明显感受到 Agent 的"迟钝"。
6.2 并发执行:从串行到并行
现代 LLM API 支持在一次响应中返回多个工具调用请求。Claude 的 API 允许模型在一个 assistant message 中包含多个 tool_use content block,OpenAI 的 API 则通过 tool_calls 数组实现类似能力。这为并发执行提供了基础。
并发执行的核心思路是:如果模型一次返回了多个工具调用,说明它认为这些调用之间没有依赖关系,可以同时执行。
typescript
async function agentLoopWithConcurrency(messages: Message[]) {
while (true) {
const response = await llm.chat(messages);
if (response.stopReason === "end_turn") {
return response.content;
}
// 并发执行所有工具调用
const results = await Promise.allSettled(
response.toolCalls.map(toolCall => executeTool(toolCall))
);
// 将所有结果按顺序推入消息
for (let i = 0; i < results.length; i++) {
const result = results[i];
const content = result.status === "fulfilled"
? result.value
: `Error: ${result.reason}`;
messages.push({
role: "tool",
content,
toolCallId: response.toolCalls[i].id,
});
}
}
}这里用 Promise.allSettled 而不是 Promise.all 是一个关键的工程决策。Promise.all 在任意一个 Promise reject 时会立即 reject 整体,而 Promise.allSettled 会等待所有 Promise 完成,无论成功还是失败。对于工具执行来说,一个工具失败不应该阻止其他工具返回结果——模型需要看到所有结果(包括错误)来做下一步决策。
Claude Code 的异步生成器模式
Claude Code 的工具编排比上面的简单示例复杂得多。它使用了异步生成器(async generator)模式来实现流式工具执行。核心思路是:
typescript
async function* executeToolsConcurrently(
toolCalls: ToolCall[]
): AsyncGenerator<ToolResult> {
const promises = toolCalls.map(async (toolCall) => {
const result = await executeTool(toolCall);
return { toolCall, result };
});
// 使用竞速模式,谁先完成谁先 yield
const pending = new Set(promises);
while (pending.size > 0) {
const { promise, value } = await Promise.race(
[...pending].map(p => p.then(v => ({ promise: p, value: v })))
);
pending.delete(promise);
yield value;
}
}这个模式的精妙之处在于:结果按完成顺序流式返回,而不是按请求顺序。 如果 5 个文件读取中有一个特别快,它的结果会立即被处理,而不需要等待其他 4 个。这对用户体验至关重要——用户可以在终端中实时看到工具执行的进度,而不是盯着空白屏幕等所有工具执行完毕。
异步生成器的另一个优势是它与 Agent Loop 的消息流天然兼容。Agent Loop 本身就是一个持续产出消息的流式过程,工具执行结果可以无缝汇入这个流。
6.3 工具依赖图:当并发遇到依赖
并非所有工具调用都可以并发执行。考虑这个场景:Agent 需要先搜索文件找到目标路径,再读取该文件,最后修改它。搜索 → 读取 → 修改之间存在严格的数据依赖。
在实践中,依赖关系有两种来源:
显式依赖——一个工具的输入参数来自另一个工具的输出。比如 grep 返回的文件路径被传给 read。这种依赖由模型自然管理:模型不会在一次响应中同时请求 grep 和 read(因为它还不知道 grep 的结果),它会先请求 grep,拿到结果后再请求 read。
隐式依赖——两个工具操作同一资源,存在竞态条件。比如同时读取和写入同一个文件,或者两个 bash 命令操作同一个目录。这种依赖模型未必能识别,需要 Harness 层来管理。
typescript
// 隐式依赖检测的简化实现
function detectConflicts(toolCalls: ToolCall[]): Map<string, ToolCall[]> {
const resourceMap = new Map<string, ToolCall[]>();
for (const call of toolCalls) {
const resources = extractResources(call); // 提取工具操作的资源
for (const resource of resources) {
if (!resourceMap.has(resource)) {
resourceMap.set(resource, []);
}
resourceMap.get(resource)!.push(call);
}
}
// 返回有冲突的资源及其相关工具调用
return new Map(
[...resourceMap.entries()].filter(([_, calls]) => {
// 多个写操作或读写混合都算冲突
return calls.length > 1 && calls.some(c => isWriteOperation(c));
})
);
}Claude Code 在这方面的处理策略比较务实:它信任模型的判断。 如果模型在一次响应中返回了多个工具调用,Claude Code 就认为它们可以并发执行。但它对某些特殊工具做了保护——比如 Bash 工具,同一时刻只允许一个 Bash 命令执行,因为 shell 状态是全局共享的。
这种"默认信任 + 特殊保护"的策略在实践中效果不错。完全精确的依赖分析成本太高(本质上是静态分析问题),而模型对任务依赖关系的理解通常足够准确。
6.4 速率限制与资源管理
并发执行带来效率提升的同时,也带来了资源管理问题。如果不加限制,一个 Agent 可能同时发起 100 个文件读取、50 个网络请求、20 个子进程。这不仅会耗尽系统资源,还可能触发外部 API 的速率限制。
资源管理的核心模式是信号量(Semaphore):
typescript
class Semaphore {
private permits: number;
private waiting: Array<() => void> = [];
constructor(permits: number) {
this.permits = permits;
}
async acquire(): Promise<void> {
if (this.permits > 0) {
this.permits--;
return;
}
return new Promise(resolve => this.waiting.push(resolve));
}
release(): void {
if (this.waiting.length > 0) {
this.waiting.shift()!();
} else {
this.permits++;
}
}
}
// 按工具类型设置不同的并发上限
const limits: Record<string, Semaphore> = {
file_read: new Semaphore(10), // 最多 10 个并发文件读取
bash: new Semaphore(1), // bash 串行执行
web_fetch: new Semaphore(5), // 最多 5 个并发网络请求
default: new Semaphore(20), // 默认上限
};
async function executeToolWithLimit(toolCall: ToolCall): Promise<ToolResult> {
const semaphore = limits[toolCall.name] ?? limits.default;
await semaphore.acquire();
try {
return await executeTool(toolCall);
} finally {
semaphore.release();
}
}不同类型的工具应该有不同的并发限制。文件读取相对安全,可以放宽;bash 命令涉及全局状态,需要严格限制;网络请求受外部 API 限制,需要适配对方的 rate limit。
在生产环境中,资源管理还需要考虑全局配额。如果一个用户同时运行多个 Agent 会话,它们共享的系统资源需要在会话之间做公平调度。这已经超出了单个 Agent 的范畴,进入了平台工程的领域。
6.5 工具调用验证
在执行工具之前,验证调用参数是一个容易被忽略但极其重要的环节。模型生成的参数可能存在各种问题:
- 类型错误:期望数字,传了字符串
- 路径穿越:
../../etc/passwd这样的恶意路径 - 参数缺失:必填参数没有提供
- 超出范围:文件内容超过合理长度
typescript
interface ToolValidator {
validate(params: unknown): ValidationResult;
sanitize(params: unknown): unknown;
}
function validateToolCall(toolCall: ToolCall): ValidationResult {
// 1. JSON Schema 验证:检查类型和必填字段
const schemaResult = validateSchema(toolCall.name, toolCall.params);
if (!schemaResult.valid) {
return { valid: false, error: schemaResult.errors };
}
// 2. 语义验证:检查参数值的合理性
if (toolCall.name === "file_read") {
const path = toolCall.params.path as string;
if (path.includes("..")) {
return { valid: false, error: "Path traversal detected" };
}
if (!path.startsWith(allowedRoot)) {
return { valid: false, error: "Path outside allowed directory" };
}
}
// 3. 输入消毒
toolCall.params = sanitize(toolCall.name, toolCall.params);
return { valid: true };
}验证失败时的处理策略也值得思考。最简单的做法是直接返回错误给模型,让它修正参数。更优雅的做法是在某些情况下自动修正——比如自动补全相对路径为绝对路径,或者截断过长的输入。
Claude Code 在验证方面做了大量工作。它会检查文件路径是否在项目目录内,验证 bash 命令是否包含危险操作(如 rm -rf /),确认编辑操作的目标文件确实存在。这些验证在第 14 章的权限模型中会有更详细的讨论。
6.6 超时管理
工具执行可能因为各种原因卡住:网络请求超时、子进程死锁、文件系统挂起。超时管理是工具编排中不可或缺的一环。
超时有两个层次:单工具超时和全局循环超时。
typescript
// 单工具超时
async function executeWithTimeout(
toolCall: ToolCall,
timeoutMs: number
): Promise<ToolResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await executeTool(toolCall, { signal: controller.signal });
} catch (error) {
if (error instanceof AbortError) {
return {
toolCallId: toolCall.id,
content: `Tool execution timed out after ${timeoutMs}ms`,
isError: true,
};
}
throw error;
} finally {
clearTimeout(timer);
}
}
// 全局循环超时
async function agentLoopWithGlobalTimeout(
messages: Message[],
globalTimeoutMs: number
) {
const deadline = Date.now() + globalTimeoutMs;
while (Date.now() < deadline) {
const remaining = deadline - Date.now();
const response = await withTimeout(llm.chat(messages), remaining);
// ... 执行工具,检查剩余时间
}
return "Agent loop timed out";
}不同工具类型应该有不同的超时时间。文件读取可以设得短(5 秒),bash 命令需要设得长(120 秒甚至更长,因为编译或测试可能很耗时),网络请求视情况而定。
Claude Code 对 bash 工具的超时处理特别值得关注。它允许用户在工具定义中指定超时时间,默认是 120 秒。超时后不是简单地杀死进程,而是先尝试发送 SIGTERM,等待一段时间,如果进程仍未退出再发 SIGKILL。这种优雅退出机制避免了资源泄漏。
全局超时则防止 Agent 进入无限循环。即使每次单独的工具调用都没有超时,Agent 可能因为无法完成任务而反复调用工具。设置一个全局时间上限是必要的安全措施。
6.7 批处理:当量变引起质变
有些场景下,Agent 需要对大量相似的目标执行相同操作——比如重命名一个在 50 个文件中出现的变量,或者在一个大型项目中搜索所有匹配某个模式的文件。逐个调用工具既低效又浪费 token。
批处理的核心思想是:将多个相似的工具调用合并为一个。
typescript
// 单次调用 vs 批量调用的对比
// 不好:50 次独立的 grep 调用
for (const pattern of patterns) {
await grep({ pattern, path: projectRoot });
}
// 好:一次 grep 调用覆盖多个模式
await grep({ pattern: patterns.join("|"), path: projectRoot });批处理不仅发生在工具内部,也可以发生在 Harness 层。如果 Harness 检测到模型连续请求了多个相同类型的工具调用,可以将它们合并执行:
typescript
function batchToolCalls(toolCalls: ToolCall[]): ToolCall[] {
const groups = groupBy(toolCalls, call => call.name);
const batched: ToolCall[] = [];
for (const [name, calls] of Object.entries(groups)) {
if (name === "file_read" && calls.length > 3) {
// 将多个文件读取合并为一个批量读取
batched.push({
name: "batch_file_read",
params: { paths: calls.map(c => c.params.path) },
id: generateId(),
});
} else {
batched.push(...calls);
}
}
return batched;
}Claude Code 在文件操作上大量使用了批处理思维。它的 Glob 工具可以一次匹配多个模式,Grep 工具可以在多个文件类型中搜索。这些工具的设计本身就是批处理友好的——它们的参数空间允许单次调用覆盖广泛的操作范围。
批处理的挑战在于结果映射。原始调用和合并后的调用之间存在一对多的关系,需要正确地将批量结果拆分回每个原始调用的上下文中。如果不处理好这一点,模型可能无法正确关联工具调用和结果。
6.8 真实实现:Claude Code 的完整编排流程
把上面的各个组件组合起来,我们可以还原 Claude Code 的工具编排全貌:
typescript
async function* orchestrateTools(
toolCalls: ToolCall[],
config: OrchestrationConfig
): AsyncGenerator<ToolEvent> {
// 第一步:验证所有工具调用
const validated = toolCalls.map(call => {
const result = validateToolCall(call);
if (!result.valid) {
return { call, error: result.error };
}
return { call, error: null };
});
// 立即 yield 验证失败的结果
for (const v of validated.filter(v => v.error)) {
yield {
type: "tool_error",
toolCallId: v.call.id,
error: v.error,
};
}
const validCalls = validated
.filter(v => !v.error)
.map(v => v.call);
// 第二步:检测冲突,拆分为可并发组
const groups = splitIntoConcurrentGroups(validCalls);
// 第三步:按组顺序执行,组内并发
for (const group of groups) {
const promises = group.map(async (call) => {
// 获取信号量
const semaphore = getSemaphore(call.name);
await semaphore.acquire();
try {
// 带超时执行
const timeout = getTimeout(call.name, config);
const result = await executeWithTimeout(call, timeout);
return { type: "tool_result" as const, ...result };
} catch (error) {
return {
type: "tool_error" as const,
toolCallId: call.id,
error: String(error),
};
} finally {
semaphore.release();
}
});
// 竞速 yield 结果
const pending = new Set(promises);
while (pending.size > 0) {
const settled = await Promise.race(
[...pending].map(p => p.then(v => ({ p, v })))
);
pending.delete(settled.p);
yield settled.v;
}
}
}这个实现体现了几个关键设计决策:
- 验证前置:在任何执行之前完成所有验证,尽早失败
- 分组并发:有冲突的调用被分到不同组,组间串行、组内并发
- 信号量限流:每种工具类型有独立的并发上限
- 带超时执行:每个工具调用都有独立的超时控制
- 流式返回:结果按完成顺序 yield,不阻塞
这五层机制叠加起来,形成了一个既高效又安全的工具执行引擎。
6.9 与 LangGraph 的对比
LangGraph 对工具执行采取了截然不同的架构思路。在 LangGraph 中,工具执行被建模为图中的一个节点(ToolNode):
python
from langgraph.prebuilt import ToolNode
tool_node = ToolNode([search_tool, calculator_tool, file_tool])
graph = StateGraph(AgentState)
graph.add_node("agent", call_model)
graph.add_node("tools", tool_node)
graph.add_edge("agent", "tools")
graph.add_edge("tools", "agent")ToolNode 内部也支持并发执行——当 LLM 返回多个工具调用时,ToolNode 会并发执行它们。但关键区别在于抽象层次。
Claude Code 把工具编排看作 Agent Loop 的内部实现细节。工具执行嵌在循环体内部,编排逻辑和循环逻辑紧密耦合。这样做的好处是执行效率高、可以做细粒度的优化(如流式返回),代价是编排逻辑难以独立复用。
LangGraph 则把工具执行外化为图的一个可组合节点。好处是清晰的职责分离和强大的可组合性——你可以在 ToolNode 前后插入任意的预处理和后处理节点(比如权限检查节点、日志节点)。代价是额外的抽象层带来的性能开销和概念复杂度。
| 维度 | Claude Code | LangGraph |
|---|---|---|
| 抽象模型 | 循环内嵌编排 | 图节点 |
| 并发机制 | async generator + Promise.allSettled | asyncio.gather |
| 流式支持 | 原生,按完成顺序 yield | 需要额外配置 |
| 可组合性 | 低,紧耦合 | 高,节点可插拔 |
| 性能开销 | 低 | 中等 |
| 状态管理 | 手动 | 图状态自动传递 |
两种方式没有绝对的优劣。Claude Code 的方式更适合单体 Agent 应用,追求极致的执行效率和用户体验;LangGraph 的方式更适合复杂的多步编排场景,追求架构的清晰和灵活。
6.10 本章小结
工具编排是 Harness Engineering 中最"工程"的部分之一。它不涉及 AI 理论,纯粹是软件工程问题——并发控制、资源管理、超时处理、输入验证。但正是这些"无聊"的工程细节,决定了 Agent 在生产环境中是流畅运行还是频繁卡死。
本章的核心要点:
- 从顺序到并发是效率的关键跳跃。现代 LLM API 原生支持多工具调用,利用这一能力可以大幅提升 Agent 的执行速度。
- 依赖管理可以信任模型但要兜底。模型通常能正确处理显式依赖,但隐式依赖需要 Harness 层保护。
- 资源管理用信号量模式。不同工具类型设置不同的并发上限,防止资源耗尽。
- 验证是最便宜的保险。在执行前验证参数,远比在执行后处理错误成本低。
- 超时要分层。单工具超时防止个别调用卡死,全局超时防止 Agent 无限循环。
- 批处理是量变到质变的优化。当相似操作数量超过阈值时,合并执行能带来数量级的效率提升。
下一章我们将讨论当工具执行出错时怎么办——工具错误恢复是工具编排的另一面。设计得再好的编排系统,也无法避免工具在运行时失败,关键是如何优雅地处理这些失败。