如果你已經(jīng)會調(diào)用大模型,也做過最基礎(chǔ)的“讓模型選擇工具并發(fā)起調(diào)用”的小實驗,接下來很容易遇到一個落差:
模型明明已經(jīng)能“調(diào)用工具”了,但系統(tǒng)還是不太像一個真正能交付任務(wù)的 agent。
它可能會查資料、會讀文件、也會跑命令,但一旦任務(wù)稍微復(fù)雜一點(diǎn),就會出現(xiàn)這些問題:
- 工具雖然調(diào)用成功了,但結(jié)果沒法穩(wěn)定接回后續(xù)推理;
- 工具很多,但模型不知道什么時候該用;
- 工具并發(fā)一開,狀態(tài)就亂;
- 同樣叫“工具”,讀文件和改文件明明風(fēng)險完全不同,runtime 卻一視同仁。
這說明真正難的地方,從來不是“給模型接幾個函數(shù)”,而是怎么把工具變成一套可調(diào)度、可約束的執(zhí)行系統(tǒng)。
這篇文章不講工具列表,也不做 API 說明書。我們換一個更適合 agent 開發(fā)者的視角:Claude Code 到底是怎么把工具組織成 runtime 的,以及這套設(shè)計為什么值得借鑒。
1. 先建立一個正確心智模型:工具不是函數(shù)表,而是 runtime 合同
很多新人第一次做 agent,會先寫出這樣一份工具表:
async function readFile(filePath) {
return fs.readFile(filePath, 'utf8');
}
const tools = {
readFile,
editFile,
searchWeb,
runShell,
};
這當(dāng)然能跑,但它只解決了一個很窄的問題:模型能不能按要求發(fā)起一次工具調(diào)用。
真正難的不是“會不會調(diào)”,而是這次調(diào)用能不能被校驗、被約束、被執(zhí)行,并且把結(jié)果穩(wěn)定地接回后續(xù)推理。
把這種小實驗往“能穩(wěn)定完成任務(wù)”推進(jìn),馬上會遇到另一組問題。這里先不急著下結(jié)論,先把問題攤開看:
- 模型看到的工具名字、描述、輸入結(jié)構(gòu),到底由誰定義?
- 一個工具在當(dāng)前會話里是否應(yīng)該暴露,能不能動態(tài)下線?
- 模型生成的參數(shù)格式不對、值不合法時,誰來兜底?
- 這次調(diào)用是只讀、寫入,還是潛在破壞性操作?
- 調(diào)用前是否需要權(quán)限判斷、hook(流程前后插入的額外邏輯)、審計或用戶確認(rèn)?
- 兩個工具能不能并發(fā),不該由“是否異步”決定,那該由什么決定?
- 工具執(zhí)行完之后,結(jié)果應(yīng)該怎樣回流,模型下一輪才看得懂?
- UI 展示給用戶的內(nèi)容,為什么不能直接等于工具內(nèi)部返回值?
這里先把問題壓住,不急著解釋。接下來幾章,我們就按這條問題鏈往下走:先看一個工具是怎么被定義出來的,再看它如何進(jìn)入會話、如何被執(zhí)行、如何參與并發(fā),最后再回到文章開頭的那些問題。
2. 如何寫一個工具:從 Tool 抽象到 readFile 的實現(xiàn)
Claude Code 的第一步,不是直接散落地實現(xiàn)一堆工具,而是先定義統(tǒng)一的 Tool 合同。你可以把它粗略理解成下面這個樣子:
type Tool = {
name: string;
inputSchema: Schema;
outputSchema: Schema;
description(): Promise<string>;
prompt(): Promise<string>;
validateInput(input): ValidationResult;
checkPermissions(input, context): PermissionResult;
isReadOnly(input): boolean;
isConcurrencySafe(input): boolean;
call(input, context): Promise<Result>;
// 概念層:把工具內(nèi)部結(jié)果整理成“可回寫”的標(biāo)準(zhǔn)結(jié)果
formatResult?(result): ToolResult;
// 實現(xiàn)層:把結(jié)果映射成真正寫入消息流的 tool_result block 參數(shù)
mapToolResultToToolResultBlockParam(
result,
toolUseId,
): ToolResultBlockParam;
};
這里補(bǔ)一層說明,避免把兩個不同層次的接口看成沖突:上面的 formatResult 是為了幫助理解而抽象出來的“結(jié)果格式化”能力;落到 Claude Code 的實際實現(xiàn)時,更常見的是更具體的 mapToolResultToToolResultBlockParam(result, toolUseId)。它比 formatResult 多帶一個 toolUseId,返回的也不是泛化的 ToolResult,而是可以直接寫回會話消息流的 tool_result block 參數(shù)。下面進(jìn)入結(jié)果回寫時,我們統(tǒng)一按這個更貼近實現(xiàn)的名字展開。
第一次看到這段接口,很容易被字段數(shù)量嚇到。更好的讀法不是逐個背字段,而是先把它拆成 3 組:
2.1 模型接口:告訴模型“這個工具叫什么、怎么用”
這一組字段決定的是,模型眼里看到的工具協(xié)議是什么:
nameinputSchemaoutputSchemadescription()prompt()
你可以把它們理解成“給模型看的那一面”。名字、描述和輸入結(jié)構(gòu)說不清,模型連怎么發(fā)起一次穩(wěn)定調(diào)用都做不到。
2.2 runtime 控制:告訴系統(tǒng)“這次調(diào)用該怎么被約束”
這一組字段決定的是,runtime 要怎么管理這次調(diào)用:
validateInput(input)checkPermissions(input, context)isReadOnly(input)isConcurrencySafe(input)
這里的重點(diǎn)不是“能不能調(diào)用”,而是“系統(tǒng)該不該放行、該怎么調(diào)度、能不能并發(fā)”。
2.3 執(zhí)行與結(jié)果回寫:工具做完以后,結(jié)果怎么回到會話里
這一組要解決的問題很具體:工具執(zhí)行完以后,結(jié)果不能只停留在程序內(nèi)部,還得回到會話里,成為模型下一輪真正能看到的上下文。
對應(yīng)到代碼里,通常會分成兩步:
-
call(input, context)負(fù)責(zé)真正執(zhí)行工具; -
mapToolResultToToolResultBlockParam(result, toolUseId)負(fù)責(zé)把執(zhí)行結(jié)果整理成要寫回會話的結(jié)構(gòu)。
在 Claude Code 里,工具執(zhí)行完不是終點(diǎn)。對 agent 來說,結(jié)果還要被穩(wěn)定地寫回會話,后面的推理才能接上。
這也是為什么這里要把“執(zhí)行”和“結(jié)果回寫”分開看。
-
call()返回的,通常是工具內(nèi)部更方便處理的數(shù)據(jù)。比如讀文件工具內(nèi)部可能先返回{ content, filePath, lineCount }這種結(jié)構(gòu),方便后續(xù)代碼繼續(xù)加工; -
mapToolResultToToolResultBlockParam(...)再把這些數(shù)據(jù)整理成標(biāo)準(zhǔn)化的tool_result; - 這個
tool_result會被寫回會話,變成模型下一輪真正能讀到的內(nèi)容。
如果用偽代碼表示,大概是這樣:
const result = await tool.call(input, context);
// 比如:工具內(nèi)部先返回
// { content, filePath, lineCount }
const toolResult = tool.mapToolResultToToolResultBlockParam(result, toolUseId);
// 然后整理成會話里真正要寫回的結(jié)構(gòu)
// { type: 'tool_result', tool_use_id: 'xxx', content: '...' }
為什么不讓 call() 直接返回最終要寫回會話的結(jié)果?因為這兩層處理的是兩類不同的問題:
-
call()關(guān)注的是“工具內(nèi)部怎么把事情做完”; -
mapToolResultToToolResultBlockParam(...)關(guān)注的是“做完以后,怎樣把結(jié)果變成統(tǒng)一的會話格式”。
分開以后,工具內(nèi)部可以保留自己最自然的數(shù)據(jù)結(jié)構(gòu),而整個系統(tǒng)在寫回會話時,仍然能保持統(tǒng)一格式。
如果沒有這一步,就很容易出現(xiàn)一種很典型的情況:程序里明明拿到了結(jié)果,但模型下一輪像沒看見一樣,因為結(jié)果沒有被整理成它真正能繼續(xù)讀取的會話內(nèi)容。
所以這一組能力其實只在解決兩件事:工具怎么真正執(zhí)行,以及執(zhí)行完以后,結(jié)果怎么回到會話里,變成后續(xù)對話還能繼續(xù)使用的上下文。
2.4 buildTool:統(tǒng)一創(chuàng)建工具,并補(bǔ)齊默認(rèn)行為
源碼里還有一個很值得借鑒的小設(shè)計:buildTool。它不是語法糖,而是在用默認(rèn)值強(qiáng)制大家走統(tǒng)一的安全基線:
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: () => false,
isReadOnly: () => false,
isDestructive: () => false,
checkPermissions: input =>
Promise.resolve({ behavior: 'allow', updatedInput: input }),
};
這里最重要的一點(diǎn)是:安全相關(guān)默認(rèn)保守,便利性相關(guān)默認(rèn)補(bǔ)齊。你可以把 buildTool 理解成一個統(tǒng)一創(chuàng)建工具、并順手補(bǔ)齊默認(rèn)行為的函數(shù)。這樣每個官方工具在進(jìn)入系統(tǒng)之前,都會先落到同一套基礎(chǔ)規(guī)則上,而不是各寫各的。
2.5 用 readFile 看一個工具是怎么落地的
有了這個抽象,再看 readFile 就更容易理解了。Claude Code 里的 FileReadTool 并不是 fs.readFile 的薄封裝,而是一個完整工具:
export const FileReadTool = buildTool({
name: FILE_READ_TOOL_NAME,
maxResultSizeChars: Infinity,
strict: true,
userFacingName,
isConcurrencySafe() {
return true;
},
isReadOnly() {
return true;
},
async checkPermissions(input, context) {
return checkReadPermissionForTool(
FileReadTool,
input,
context.getAppState().toolPermissionContext,
);
},
async validateInput({ file_path, pages }, toolUseContext) {
// 參數(shù)值校驗
},
async call(input, context) {
const filePath = resolveFilePath(input.file_path);
const content = await readFile(filePath, 'utf8');
return {
filePath,
content,
lineCount: content.split('\n').length,
};
},
});
這個例子能把 Tool 合同講得非常具體。
第一,它先聲明自己是只讀、可并發(fā)的。也就是說,并發(fā)策略不是執(zhí)行器拍腦袋猜出來的,而是工具自己聲明。
第二,它有單獨(dú)的 checkPermissions。讀文件看起來風(fēng)險低,但依然要走文件系統(tǒng)權(quán)限規(guī)則,而不是因為“只是 Read”就繞過 runtime。
第三,它有自己的 validateInput。模型就算知道 file_path、offset、limit 這些字段,也不代表它一定會給出合法值。比如 PDF 的 pages 范圍、偏移參數(shù)的邊界,都需要工具自己兜底。
第四,它的 call 里處理的遠(yuǎn)不只是文本讀取。源碼里還能看到這些邏輯:
- 圖片、PDF、Notebook 走不同分支;
- 大文件和 token 上限單獨(dú)約束;
- 特殊設(shè)備路徑會被攔截,避免讀
/dev/zero這類會卡住進(jìn)程的路徑; - 結(jié)果會帶行號、分頁信息,方便模型繼續(xù)引用;
- 系統(tǒng)還會記住“這次到底讀了哪個文件、讀到什么版本”的內(nèi)部狀態(tài),給后續(xù)編輯和一致性校驗使用。
所以從 runtime 視角看,readFile 的真實職責(zé)不是“把磁盤內(nèi)容拿出來”,而是“把受約束、可解釋、可繼續(xù)推理的上下文安全注入會話”。
到這里,再回頭看標(biāo)題里的“runtime 合同”,它至少已經(jīng)不只是一個比喻了:只要你開始認(rèn)真處理 schema、權(quán)限、只讀性、并發(fā)性和結(jié)果映射,工具就不再是一個裸函數(shù)。
換句話說,這一章真正回答的是:誰來定義工具協(xié)議,參數(shù)不合法時誰兜底,讀寫風(fēng)險和權(quán)限檢查又該放在哪一層。Claude Code 的答案不是“調(diào)度層臨時判斷”,而是把這些能力直接內(nèi)建進(jìn) Tool 合同。
3. 工具注冊
工具定義完,不代表模型立刻就能看到它。Claude Code 還有一層專門的注冊邏輯,用來回答另一個常被忽略的問題:
當(dāng)前這一輪,到底該給模型開放哪些能力?
基礎(chǔ)入口在 getAllBaseTools()。它先組出一套“理論上可用”的內(nèi)建工具集合:
export function getAllBaseTools(): Tools {
return [BashTool, FileReadTool, FileEditTool, WebFetchTool, ...extraTools];
}
但真正給當(dāng)前會話用的,不是這份靜態(tài)列表,而是 getTools(permissionContext) 再過濾一遍:
export const getTools = (permissionContext: ToolPermissionContext): Tools => {
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
return [BashTool, FileReadTool, FileEditTool];
}
let allowedTools = filterToolsByDenyRules(
getAllBaseTools(),
permissionContext,
);
return allowedTools.filter(tool => tool.isEnabled());
};
這個注冊鏈路至少做了三件事。
第一,區(qū)分“實現(xiàn)了”與“暴露了”。工具寫在代碼里,不代表本輪就該給模型看見。
第二,把環(huán)境和模式帶進(jìn)來。simple 模式下,系統(tǒng)會主動退化成極小工具集,而不是把所有能力都開放給模型。
第三,把 deny rules 和 isEnabled() 作為注冊階段的一部分,而不是等模型調(diào)用時才拒絕。這樣做的意義很大,因為它減少了模型的決策噪音,也縮小了高風(fēng)險能力的暴露面。
這也是為什么 Claude Code 的工具系統(tǒng)更像“能力管理系統(tǒng)”,而不是一個函數(shù)目錄。注冊層要解決的不是“還有哪些函數(shù)沒掛上”,而是“當(dāng)前這輪對話里,哪些能力應(yīng)該被模型看見”。
所以這一章想回答的問題其實很簡單:一個工具就算已經(jīng)寫好了,為什么這一輪會話里仍然可能不該暴露給模型。Claude Code 的做法是把“實現(xiàn)”和“暴露”明確分成兩層,先有能力,再決定此刻要不要公開。
4. 工具的生命周期
當(dāng)模型真的產(chǎn)出一個 tool_use 之后,Claude Code 也不是立刻 tool.call()。它走的是一條明確分層的生命周期。
用源碼里的 runToolUse 和 checkPermissionsAndCallTool 來壓縮,大致是下面這條鏈:
flowchart TD
A["模型產(chǎn)出 tool_use"] --> B["按 name 找到 Tool"]
B --> C["按輸入結(jié)構(gòu)先做基礎(chǔ)解析"]
C --> D["validateInput"]
D --> E["PreToolUse hooks"]
E --> F["權(quán)限決策"]
F --> G["tool.call"]
G --> H["整理成標(biāo)準(zhǔn)化結(jié)果"]
H --> I["PostToolUse hooks"]
I --> J["寫回會話,繼續(xù)推理"]
如果只看核心代碼,味道是這樣的:
// 先按輸入結(jié)構(gòu)做基礎(chǔ)解析
const parsedInput = parseInputBySchema(tool.inputSchema, input)
// 再做更細(xì)的參數(shù)校驗
const isValidCall = await tool.validateInput?.(parsedInput.data, toolUseContext)
let processedInput = isValidCall.updatedInput ?? parsedInput.data
let hookPermissionResult
// 運(yùn)行前置 hooks:
// 1. 可能補(bǔ)充消息
// 2. 可能追加額外上下文
// 3. 可能改寫輸入
// 4. 也可能直接阻斷執(zhí)行
for await (const result of runPreToolUseHooks(
toolUseContext,
tool,
processedInput,
toolUseID,
messageId,
requestId,
mcpServerType,
mcpServerBaseUrl,
)) {
// 根據(jù) hook 返回的類型,更新輸入、記錄消息或中止執(zhí)行
}
// 綜合 hook 和權(quán)限系統(tǒng)的結(jié)果,決定這次調(diào)用能不能繼續(xù)
const permissionDecision = await resolveHookPermissionDecision(...)
// 真正執(zhí)行工具
const result = await tool.call(processedInput, context, canUseTool, assistantMessage)
// 把工具內(nèi)部結(jié)果整理成會話里統(tǒng)一的返回格式
const toolResultBlock = formatToolResult(result.data, toolUseID)
// 運(yùn)行后置 hooks,做補(bǔ)充處理
for await (const hookResult of runPostToolUseHooks(tool, result, context)) {
// hook 可以追加消息、記錄信息,或補(bǔ)充處理結(jié)果
}
這段流程可以先按 4 步來理解。
第一步,先處理輸入。系統(tǒng)會先按輸入結(jié)構(gòu)做基礎(chǔ)解析,再交給 validateInput 做更細(xì)的參數(shù)校驗。前者更像“字段類型對不對”,后者更像“字段值能不能這樣用”。
第二步,處理執(zhí)行前的控制邏輯。PreToolUse hooks 會在真正執(zhí)行之前跑一遍,它們可以補(bǔ)充消息、追加額外上下文、改寫輸入,甚至直接阻斷這次調(diào)用。接著,權(quán)限系統(tǒng)再根據(jù)當(dāng)前規(guī)則決定這次工具調(diào)用是否允許繼續(xù)。
第三步,真正執(zhí)行工具。到了 tool.call(...),系統(tǒng)才開始做這次調(diào)用真正要做的事情,比如讀文件、改文件、執(zhí)行命令,或者訪問外部能力。
第四步,把結(jié)果寫回會話。tool.call(...) 返回的往往還是工具內(nèi)部更方便處理的數(shù)據(jù),系統(tǒng)還要再把它整理成統(tǒng)一的結(jié)果格式,寫回會話里。只有這樣,模型下一輪才能繼續(xù)讀到這次調(diào)用真正產(chǎn)生了什么。
這里最容易被忽略的,其實就是第四步。很多系統(tǒng)把“工具執(zhí)行成功”當(dāng)成結(jié)束,但對 agent 來說,這還不夠。工具結(jié)果只有重新進(jìn)入會話,才會變成后續(xù)推理真正可用的上下文。
所以在 Claude Code 里,工具結(jié)果首先服務(wù)的是后續(xù)推理,其次才是界面展示。比如 FileReadTool 在界面里可能只顯示“讀取了多少行”,但寫回會話的結(jié)果會帶真正的文件內(nèi)容、行號和必要提醒。這兩層故意分開,就是為了同時服務(wù)系統(tǒng)推理和交互界面。
如果回到文章開頭的問題,這一章真正補(bǔ)上的,是工具調(diào)用中間那條最容易被忽略的主鏈路:參數(shù)校驗放在哪里,權(quán)限與 hook 插在什么位置,工具結(jié)果又是怎么重新回到下一輪推理里的。
5. 工具并行相關(guān),并發(fā)策略
工具并發(fā)是另一個最容易被做壞的地方。很多系統(tǒng)默認(rèn)“能 async 就并發(fā)”,Claude Code 不是這個思路。
這里還有一個很關(guān)鍵的問題:并發(fā)不是憑空出現(xiàn)的,也不是開發(fā)者在業(yè)務(wù)代碼里手動寫死“這兩個工具一起跑”。更常見的情況是,模型在一輪里提出了多個工具調(diào)用,執(zhí)行器再進(jìn)一步判斷這些調(diào)用能不能并發(fā)執(zhí)行。
更接近真實輸出的形態(tài),大概像這樣:
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'toolu_01',
name: 'Read',
input: { file_path: 'src/a.ts' },
},
{
type: 'tool_use',
id: 'toolu_02',
name: 'Read',
input: { file_path: 'src/b.ts' },
},
],
}
也就是說,模型這一輪不是只給出一個調(diào)用,而是一次性給出了兩個讀取請求。到了這一步,執(zhí)行器才會繼續(xù)判斷:這兩個 Read 能不能一起跑,還是必須排隊執(zhí)行。
也就是說,這里有兩層分工:
- 模型負(fù)責(zé)從任務(wù)角度提出“這幾個動作可以一起做”的可能性;
- runtime 負(fù)責(zé)從系統(tǒng)角度裁決“這次并發(fā)到底安不安全”。
真正決定并發(fā)是否成立的,不是模型想不想并發(fā),而是這些工具在語義上是否允許并發(fā)執(zhí)行。
StreamingToolExecutor 里的核心判斷非常直接:
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}
它真正關(guān)心的是:這次調(diào)用在語義上是否并發(fā)安全。
再結(jié)合工具定義里的聲明,你就能看懂這套策略:
-
FileReadTool明確聲明isConcurrencySafe() { return true },所以多個讀取類工具可以并發(fā)。 - 沒有顯式聲明并發(fā)安全的工具,默認(rèn)按不安全處理。
- 只要隊列里出現(xiàn)非并發(fā)安全工具,它就要求獨(dú)占執(zhí)行。
這背后的價值,不是“更保守”,而是讓并發(fā)策略與工具語義綁定,而不是與技術(shù)實現(xiàn)綁定。
因為 agent 的工具不是純函數(shù)。它們操作的是文件系統(tǒng)、終端、外部服務(wù)和會話狀態(tài)。只要涉及副作用,并發(fā)問題就不是吞吐問題,而是一致性問題。
Claude Code 在執(zhí)行器里還做了兩件很實用的事:
- 即使并發(fā)執(zhí)行,結(jié)果也會按工具出現(xiàn)順序緩沖和回放,避免會話里的結(jié)果順序被打亂。
- 如果某個并發(fā)中的工具出錯,兄弟工具可以被取消或生成合成錯誤結(jié)果,避免系統(tǒng)在半失效狀態(tài)下繼續(xù)推進(jìn)。
結(jié)語
回頭看文章開頭那幾個典型問題,其實都能在這條主線上找到位置。
工具為什么不該只是函數(shù)表,對應(yīng)的是統(tǒng)一 Tool 合同;工具為什么不能全量暴露,對應(yīng)的是注冊層;工具為什么不能拿到名字就直接執(zhí)行,對應(yīng)的是完整生命周期;工具為什么不能盲目并發(fā),對應(yīng)的是語義驅(qū)動的并發(fā)策略。
Claude Code 的工具系統(tǒng)值得借鑒,不是因為它工具多,而是因為它把工具放回了 runtime 的中心位置。對剛從“能調(diào) LLM”邁向“能做 agent”的開發(fā)者來說,這個轉(zhuǎn)變尤其關(guān)鍵:當(dāng)你開始把工具當(dāng)成合同、能力入口、執(zhí)行對象和結(jié)果回流節(jié)點(diǎn)來設(shè)計時,你才真正進(jìn)入 agent runtime 的實現(xiàn)階段。