目录先看全貌调用之前先挡掉不该跑的调用前后各拍一张快照调用之后结果出门前实测6 个调用6 种结局写在最后在 AI 编程工具里你在对话里看到的往往只有一行「调用了 edit」几秒后结果就出来了。中间那几秒引擎的执行器做的事情比想象的多。这篇顺着 WES Code 引擎的执行器代码把一次工具调用从头跟到尾。全文按代码执行的顺序走最后用 6 个调用实际跑一遍看每一关拦下了什么。先看全貌AI 一次可能发出好几个工具调用。执行器拿到之后大致做这 9 件事分组能并行的一起跑默认最多 5 个同时跑不能并行的单独跑查名单不存在的、被禁用的、这一轮没给 AI 看过的工具直接退回查参数JSON 能不能解析必填字段在不在前置钩子留给嵌入引擎的程序的扩展点可以在这里截住调用套超时按工具自己声明的超时在外面加一层截止时间调用前拍快照给工作目录记一份文件清单只读工具跳过这一步调用工具崩了也接得住超时和其他错误分开报调用后再拍一张对比出新建、修改、删除了哪些文件附在结果后面结果出门前先打码太大的写进临时文件太长的掐头去尾下面一件一件看。调用之前先挡掉不该跑的分组谁能一起跑每个工具都声明了自己能不能和别的工具同时跑。read、grep、glob、exec 可以edit、write 不行。执行器按这个把一批调用切成几段节选注释译成了中文varconcurrentBatch[]intfori,call:rangecalls{ifisConcurrentCall(call){concurrentBatchappend(concurrentBatch,i)}else{// 先把攒着的可并行调用跑完runGroup(concurrentBatch)concurrentBatchnil// 这个不能并行的单独跑runGroup([]int{i})}}// 最后一批可并行的runGroup(concurrentBatch)比如 AI 一次发了 read、grep、edit、read前两个一起跑edit 等它们跑完再单独跑最后那个 read 等 edit 跑完再跑。不能并行的调用就像一道闸前后的调用不会和它交叠。一个小细节界面上的「开始执行」事件是在抢并发名额之前就发出去的。同时排队的调用多了你在界面上也能先看到它们在排队而不是干等。三种调用直接退回真正调用之前执行器先查这个工具能不能用。三种情况直接退回工具本身一行代码都不会执行情况AI 收到的报错工具不存在unknown tool: 工具名工具被禁用tool 工具名 is disabled工具这一轮没给 AI 看过提示它用tool_search查找可用的工具或者换用当前列表里有的工具第三种最有意思。大模型有时会「记得」训练数据里见过的工具这一轮明明没提供它也照样去调。执行器会直接告诉它这个工具不在这一轮的列表里要用就先去查。接着查参数只查两样JSON 能不能解析required里的字段在不在。类型对不对不在这里查交给工具自己。执行器也不会替 AI 修 JSON。以前有过一个修参数的环节它唯一的用途后来被删掉了这个环节也就跟着拿掉了。代码注释给的理由是留着一个什么都不修的环节反而会让人以为格式不对也能过。前置钩子和超时前置钩子是留给嵌入引擎的程序用的按顺序调用第一个返回结果的钩子会直接截住这次调用工具不再执行。WES Code 目前没有挂前置钩子这一步是空的。然后是超时工具声明了多长就在外面套多长的截止时间。这层超时有个副作用我在另一篇文章里专门写过工具内部把等待时间设得再长也越不过它。超时之后AI 收到的是Tool ‘工具名’ execution timed out after 5m0s. The external service may be slow or unavailable. Consider: retry later, use alternative approach, or skip this step.时间填的是工具自己声明的超时按 Go 的写法打印300 秒显示成 5m0s。工具如果自己处理了超时、返回了结果比如 ask_userAI 收到的就是工具自己的报错不是这一条。调用前后各拍一张快照不是只读的工具执行器会在调用前给工作目录拍一张快照调用后再拍一张对比出哪些文件新建了、改了、删了附在结果后面给 AI 看。格式是这样的来自后面的实测[Workspace Changes] notes.md (created, 8 B) ~ report.txt (modified, 6 B → 1.5 KB) - old.log (deleted)快照有边界最多往下走 4 层最多记 500 个文件跳过.git、node_modules、.venv这类目录和.pyc、.swp、.tmp这类文件。有两处写法值得一看工具报错了也照样对比。工具可能在出错之前已经写了一半文件对比结果照样附在报错后面AI 才知道留下了什么调用后的那张快照不跟着取消走。这次运行可能正在被取消但文件已经写到磁盘上了这份记录必须留下来。所以拍第二张时用的是一个不会被取消的 context代价由「4 层、500 个文件」的上限兜住为什么要这么麻烦直接看工具名不行吗注释里给了一组数据统计 1290 次真实的 exec 调用40% 在写文件在 SWE 任务里59% 的源文件写入是用cat x.go EOF这种命令完成的。exec 能跑任何命令任何工具名单都列不全「哪些调用会改文件」。所以执行器按「实际看到了什么变化」来记记下来的文件会交给「先读后改」那套检查作为新的基准。这里有个局限要说清楚快照拍的是引擎的工作目录。在 WES Code 的编辑器里这个目录是引擎自己的产物目录你打开的项目是另一个目录。项目文件的改动靠 write、edit、apply_patch 这三个工具自己上报用 exec 跑命令直接改项目里的文件不会出现在这份变化清单里。调用之后结果出门前工具崩了也接得住每个调用都包在一个recover里。工具在运行时 panic 了执行器会把它变成一条普通的报错tool 工具名 panic: 原因而不是让整个进程崩掉。工具返回了空结果也会被补成一条报错。先打码再分叉结果离开执行器之后会分成三路发给界面、发回给大模型、太大时写进临时文件。打码把密钥、令牌这类值遮掉放在分叉之前。注释里说得很直白如果先写临时文件再打码完整的密钥就留在了磁盘上而那个目录 AI 自己是能读到的。太大、太长怎么办最后是控制大小有三层单行太长超过上限的那一行整行换成line truncated: N chars整个结果太大超过 2 万字符或者单个结果额度的 3 倍取大的整段写进临时文件AI 只拿到文件路径和一句「需要全文就用 read 读这个文件」超过单个结果的额度保留开头三分之一和结尾三分之一中间换成一行说明写清楚省略了多少字符、显示的是第几行到第几行以及要看中间部分该从哪一行接着读只读工具比如 read成功返回的结果额度放宽到 3 倍免得代码文件刚读一半就被截了。这一批调用全部跑完以后如果配置了总额度还会再限制一次这一轮所有结果加起来的大小。实测6 个调用6 种结局我用go run -overlay把一个小程序临时放进引擎模块直接构造引擎的执行器注册了三个假工具quiet_writer不是只读工具会新建一个文件、改一个、删一个结果里只回一句 ok什么都不说print_lines只读工具按参数输出 n 行日志crasher一运行就往 nil map 里写必然 panicquiet_writer是这样写的// 悄悄改文件的工具新建一个、改一个、删一个结果里只回一句 oktypequietWriterstruct{tool.BaseTool}func(*quietWriter)Name()string{returnquiet_writer}func(*quietWriter)Schema()tool.ToolSchema{returntool.ToolSchema{Name:quiet_writer,InputSchema:json.RawMessage({type:object,required:[text]})}}func(*quietWriter)Call(_context.Context,_json.RawMessage,tc*tool.ToolContext)(*tool.ToolResult,error){os.WriteFile(filepath.Join(tc.WorkDir,notes.md),[]byte(# notes\n),0o644)os.WriteFile(filepath.Join(tc.WorkDir,report.txt),[]byte(strings.Repeat(line\n,300)),0o644)os.Remove(filepath.Join(tc.WorkDir,old.log))returntool.ToolResult{Content:ok},nil}单个结果的额度设成 400 个字符然后一次提交 6 个调用calls:[]tool.ToolCall{{ID:1,Name:quiet_writer,Input:json.RawMessage({text:hi})},{ID:2,Name:quiet_writer,Input:json.RawMessage({})},{ID:3,Name:rm_rf,Input:json.RawMessage({})},{ID:4,Name:print_lines,Input:json.RawMessage({n:200})},{ID:5,Name:print_lines,Input:json.RawMessage({n:2000})},{ID:6,Name:crasher,Input:json.RawMessage({})},}输出第 4 种太长程序里只打印了头一行、截断说明和最后一行[1] quiet_writer IsErrorfalse ErrorKind ok [Workspace Changes] notes.md (created, 8 B) ~ report.txt (modified, 6 B → 1.5 KB) - old.log (deleted) [2] quiet_writer IsErrortrue ErrorKindinvocation tool quiet_writer: invalid input: missing required field text [3] rm_rf IsErrortrue ErrorKindinvocation unknown tool: rm_rf [4] print_lines IsErrorfalse ErrorKind line 0001 of a long build log 中间省略 ...[truncated 5200 chars; showing lines 1-14 and 187-201 of 201 total; use read with offset15 to see middle]... 中间省略 line 0200 of a long build log [5] print_lines IsErrorfalse ErrorKind Result too large (60000 chars). Full output was spilled to scratch file: $SCRATCH/tool_result_1791784113234832000.txt Use read tool on this path if you need full content. [6] crasher IsErrortrue ErrorKindexecution tool crasher panic: assignment to entry in nil map逐个对一下第 1 种工具只回了 ok执行器自己把三处文件变化附了上去第 2、3 种一个缺了必填字段一个工具名不存在都在调用之前就退回了类型都是 invocation第 4 种200 行日志共 6000 个字符。print_lines 是只读工具额度放宽到 1200首尾各保留 400中间 5200 个字符换成了一行说明说明里告诉 AI 用 offset15 接着读第 5 种2000 行、6 万个字符超过 2 万的门槛整段写进了临时文件第 6 种panic 被接住变成一条 execution 类型的报错写在最后把这条路走一遍会发现执行器的很多设计都在回答同一个问题AI 看到的结果能不能如实反映刚才发生了什么。工具没说自己改了文件就替它看工具崩了就替它报结果太大就告诉它全文放在哪里。AI 后面的每一步判断都建立在这些结果上。文中的执行器是 WES Code 引擎里的代码官网是 weisyn.com。如果你也在写 Agent工具调用这一段你是怎么处理的欢迎评论区聊聊。觉得有用的朋友欢迎点赞、收藏、关注后面会继续分享 AI 编程的实战经验。