首页 / 资讯中心 / 文章详情

终端AI助手源码还原:Agent主循环、工具调用与权限管理详解

终端AI助手源码还原:Agent主循环、工具调用与权限管理详解 ★ FEATURED ARTICLE
有个读者朋友在后台留言点名让我写一篇“Claude code源码还原”还强调要保姆级。这个方向我确实熟团队里好几个项目都从这类终端AI编码助手的源码里偷过师从命令分发到工具调用再到权限管控整套链路翻来覆去读过不止一遍。先说明一下下面要拆解的这份源码来自一个公开的实验项目不是官方仓库为了避免不必要的麻烦我统一用“A助手”来称呼它。你只要记住它的形态跟你听说的Claude code这类工具一致跑在终端里能读文件、能搜代码、能执行命令背后挂一个大模型做Agent循环。为什么值得花时间做一次源码还原因为终端AI助手比IDE插件更轻核心机制就是“主循环 工具调用 权限管理”这三板斧看懂一份成熟实现比你从零造轮子要高效得多。这篇文章适合三类人一是想弄懂终端AI编程工具内部原理的开发者二是准备在公司内部做自动化命令行助手的工程师三是想拿一个高质量TypeScript项目练手、学习工程组织方式的初学者。我尽量把每一步都写成可以直接跟着操作的程度包括用什么命令看入口、在哪几个文件里断点、遇到诡异问题怎么一步步定位。1. 还原前的准备先给源码画一张地图源码还原最忌讳的就是拿到仓库后一头扎进某个文件开始逐行读读三天还在原地打转。我的习惯是先花半小时把项目的地图画出来入口在哪、依赖哪些模块、启动时做了什么、运行时有哪些关键状态。这张地图画完后面所有细节都有地方安放读起来不会迷路。1.1 从package.json开始入口、脚本与依赖全梳理第一步永远是看package.json。A助手的package.json大致长这样{ name: a-helper, version: 0.9.2, type: module, bin: { ahelper: ./bin/acli.js }, main: ./lib/index.js, scripts: { start: node ./bin/acli.js, dev: node --watch ./bin/acli.js, test: vitest run --coverage }, dependencies: { agent-core: ^1.4.0, cli-args: ^2.1.0, colored-output: ^3.0.1, prompt-kit: ^0.8.2, table-renderer: ^1.2.0 } }这里有几个关键信息值得画到地图上。bin字段说明用户安装后执行的是bin/acli.js这就是整个程序的真实入口不是lib目录里那些漂亮模块。type: module说明全部代码走ESM规范你在源码里看到import关键字是正常的不要在CommonJS语法上浪费时间。main字段指向lib/index.js这是给其他人当库引入时用的出口像读源码这种场景main反而不重要。我还会立刻翻一下scripts和依赖列表它们能透露架构倾向。比如agent-core这个名字意味着作者把Agent主循环做成了独立核心包工具、权限、上下文管理大概率是围绕它展开的外围模块prompt-kit说明系统提示词的组织被单独抽象了table-renderer则提示结果展示层用了表格渲染这类东西。依赖名本身就是架构注释这是很多新手读源码时容易忽略的。看完package.json我会顺手跑一遍npm install然后直接用npm run dev启动确认它跟文档描述的行为一致。这个“先跑起来再看代码”的习惯非常重要带着运行时的印象去读源码比干读代码理解快一倍不止。启动成功后在项目根目录执行一次“读某个文件”的指令观察终端输出格式心里有个预期后面读代码时就能对照着确认“哦原来这段是干这个的”。1.2 主进程与REPL启动链从bin/acli.js到交互式界面的三跳入口文件通常很薄。A助手的bin/acli.js核心只有十几行#!/usr/bin/env node import { runCli } from ../lib/cli.js; runCli(process.argv.slice(2)).catch((error) { console.error(fatal:, error.message); process.exit(1); });真正的逻辑在lib/cli.js的runCli里。我读这个文件时会重点留意三件事参数解析、配置加载、REPLRead-Eval-Print Loop读取-执行-输出循环的初始化顺序。参数解析用的是一个叫cli-args的库支持--model、--directory、--verbose这类选项。解析完成后代码会加载配置文件一般放在用户主目录下和项目本地配置然后把两者合并。这里有个非常典型的设计本地配置优先生效未覆盖的字段才用全局配置。如果你以后自己写CLI工具这一套合并策略可以直接抄。接下来是启动交互式环境A助手的实现没有用现成的readline封装库而是自己写了一个轻量级状态机。核心逻辑大体上是这样的import readline from node:readline; import { handleUserInput } from ./agent/loop.js; export async function startRepl(context) { const rl readline.createInterface({ input: process.stdin, output: process.stdout, prompt: ahelper , }); rl.prompt(); rl.on(line, async (line) { const trimmed line.trim(); if (!trimmed) { rl.prompt(); return; } if (trimmed /exit || trimmed /quit) { rl.close(); return; } if (trimmed.startsWith(/)) { await handleSlashCommand(trimmed, context); } else { await handleUserInput(trimmed, context); } rl.prompt(); }); }这个实现有几个细节值得学。第一所有输入都先检查是不是斜杠命令是的话走handleSlashCommand否则才走Agent主循环的handleUserInput把内部命令和模型对话严格分开。第二每次处理完输入后都重新调用rl.prompt()保证交互界面不卡住。第三handleUserInput是异步的但readline的line事件回调没有阻塞等待这意味着如果Agent循环执行了很久用户期间输入的内容可能会堆积在事件队列里后面会讲到这个设计带来的并发坑。读完启动链我会在lib/cli.js里加几行临时日志打印出配置合并后的关键字段、当前工作目录、加载了几个工具。这样后续调试时我能第一时间确认环境是不是预期状态。源码还原的过程其实就是这样先建地图、标出入口、埋观测点然后才开始逐层深入。2. Agent主循环一次“想-做-看”的完整生命周期终端AI助手最核心的引擎不在CLI层而在Agent主循环。这里的逻辑决定了模型如何感知环境、如何决策、如何调用工具、如何从工具结果中继续学习并推进任务。我读了两遍才彻底理清它的状态流转接下来我把关键机制拆开讲。2.1 请求是怎么拼的系统提示与工具声明的注入规则模型本身没有读文件的能力它只能根据你给它的文本做出反应。所以Agent主循环做的第一件事就是拼装一个合格的请求上下文。A助手的消息组装在lib/agent/messages.js里核心思路是每次请求都由“系统提示 完整历史 新增用户输入”三部分组成。系统提示词不是一段静态文本而是动态渲染的模板。A助手用一个模板函数根据运行环境填充内容大体会包含这几块你是运行在终端里的软件工程助手。 当前工作目录{workspace} 今天日期{date} 你只能通过工具与外部世界交互禁止编造未经验证的文件内容。 可用工具{toolsJson} 安全约束{safetyRules}这里最值得关注的是toolsJson的生成方式。每个工具在被注册时都会提供一个JSON Schema描述A助手把这些Schema收集起来序列化成一份清单塞进系统提示词。模型看到这个清单才知道自己能调用哪些函数、每个函数的参数是什么格式。这个设计在原理上跟插件系统差不多工具是插槽模型是使用者Agent是调度器。历史消息的处理也有讲究。A助手不会把无限长的历史全部发给模型而是维护一个contextWindow预算。预算计算的核心代码如下export function buildMessages({ history, userInput, systemPrompt, tokenBudget }) { const budgetForHistory tokenBudget - estimateTokens(systemPrompt) - estimateTokens(userInput) - 500; const trimmed []; let used 0; for (let i history.length - 1; i 0; i--) { const item history[i]; const cost estimateTokens(item.content) 4; if (used cost budgetForHistory) { break; } trimmed.unshift(item); used cost; } return [ { role: system, content: systemPrompt }, ...trimmed, { role: user, content: userInput }, ]; }这段代码的逻辑是从最新消息往回扫描能放多少放多少直到预算用尽。倒序遍历再unshift是为了保留消息的时间顺序。细节在于末尾留了500个token的余量这是给模型的输出预留的空间不然发出去的请求可能超限。这个“输出预算预留”的习惯我自己写类似功能时也一直沿用很实用。2.2 响应解析如何从模型输出里安全地抽出工具调用模型返回的文本通常是一个混合体可能先有一句“我来看看项目结构”然后紧跟一个工具调用指令。A助手的做法是要求模型在需要调用工具时输出一段带有特定标记的结构化文本然后由解析器把它抽出来。从模型返回的原始文本中分离对话文本和工具调用A助手采用了一个正则加降级解析的两段式方案。粗略的伪代码如下export function parseResponse(raw) { const toolCalls []; let textParts []; const pattern /tool_call([\s\S]*?)\/tool_call/g; let match; let lastIndex 0; while ((match pattern.exec(raw)) ! null) { textParts.push(raw.slice(lastIndex, match.index)); const block match[1].trim(); try { toolCalls.push(JSON.parse(block)); } catch (error) { toolCalls.push({ type: parse_error, raw: block }); } lastIndex pattern.lastIndex; } textParts.push(raw.slice(lastIndex)); const text textParts.filter((part) part.trim()).join(\n).trim(); return { text, toolCalls }; }这个解析器有几个我特别欣赏的处理。第一它不会因为其中一个工具调用块解析失败就整体报错而是把解析失败的块标记成parse_error让上层决定是重试还是跳过。第二文本部分和工具调用部分是分开收集的不会被错误地拼进工具参数里。第三正则匹配时把lastIndex记录好保证后续遍历不会重复消费内容。但实际中模型输出格式千奇百怪正则解析大概率会漏。A助手为此还有一个降级策略如果一轮解析后一个工具调用都没抽出来但它又明显带有tool_call开头的半截标记主循环会请求模型重新输出并且把上一轮原始文本当作assistant消息放回上下文让模型看到自己刚才的输出格式有问题。这种“反馈-重试”机制保证了系统的鲁棒性是Agent实现中很容易被低估的部分。2.3 工具调用的分派与错误回收解析出工具调用后主循环会做一次“校验-授权-执行-回填”的完整流程。分派器的核心是一个注册表用工具的名字映射到对应的执行函数const dispatchMap new Map([ [read_file, readFileTool], [write_file, writeFileTool], [grep_search, grepSearchTool], [glob_list, globListTool], [run_command, runCommandTool], ]); export async function dispatchToolCall(toolCall, context) { const handler dispatchMap.get(toolCall.name); if (!handler) { return { success: false, error: unknown tool: ${toolCall.name}, }; } const permission await context.permissions.check(toolCall, context); if (permission deny) { return { success: false, error: permission denied by user, }; } try { const result await handler.execute(toolCall.arguments, context); return { success: true, result }; } catch (error) { return { success: false, error: error.message }; } }我特别注意到这里的权限检查放在执行之前而且工具执行函数本身不再重复鉴权。这意味着权限系统是一个前置拦截器跟业务逻辑完全解耦。这种设计让权限规则可以统一管理而不是散落在各个工具实现里。很多自己写的Agent项目权限管理混乱问题就出在把权限判断散在工具内部每个人写一套。工具执行结果无论是成功还是失败都会被打包成一条工具结果消息塞回对话历史。这个回填消息的格式是固定的模型能通过它理解刚才的操作产生了什么影响。失败消息也不会被吞掉而是原样告知模型让模型有机会调整策略。这个“错误回收”设计非常关键它避免了模型在一次工具失败后陷入死循环因为它能从中学习并换一条路。2.4 循环的出口条件与上下文窗口管理Agent主循环不是无限转的它有明确的退出条件。A助手在一次迭代结束后会做三重判断如果模型输出里没有任何工具调用说明它认为任务已经完成循环终止如果连续N轮都没有产生有效工具调用或者重复同一个错误说明它可能陷入了死胡同系统强制结束并提示用户如果累积请求轮数超过上限同样强制退出。源码里循环主体的轮廓大概是这样的export async function runAgentLoop({ messages, context, maxIterations 40 }) { let iterations 0; let lastToolSignature ; while (iterations maxIterations) { const response await context.model.complete(messages, context); const { text, toolCalls } parseResponse(response); if (toolCalls.length 0) { return { status: finished, output: text, iterations }; } for (const call of toolCalls) { const dispatch await dispatchToolCall(call, context); if (dispatch.success) { const signature ${call.name}:${JSON.stringify(call.arguments)}; if (signature lastToolSignature) { break; } lastToolSignature signature; } messages.push({ role: assistant, content: tool_call${JSON.stringify(call)}/tool_call }); messages.push({ role: tool, content: dispatch.success ? truncateToolOutput(dispatch.result) : ERROR: ${dispatch.error}, }); } iterations 1; } return { status: max_iterations_reached, iterations }; }有几个地方非常值得抄。lastToolSignature用于检测工具调用循环如果模型连续两次发起的工具调用完全一样说明它大概率在原地打转直接中断这一轮。maxIterations兜底避免了模型“思考过度”导致资源浪费。每次工具结果都走truncateToolOutput这个函数防止工具输出无限增长把对话窗口撑爆。关于截断策略我在第4章踩坑实录里还会展开讲。3. 工具层拆解读写、搜索与执行命令的三权分立看完Agent主循环再往下钻就是工具层。这部分的源码质量直接决定一个终端AI助手是“能用”还是“好用”。A助手把所有工具分成三类文件类、搜索类、命令执行类。分类的依据不是粗暴的“功能不同”而是安全边界不同。文件操作需要防路径逃逸搜索操作需要控输出量命令执行则把权限门槛拉到最高。三类工具各有一个独立的实现文件我一个个拆。3.1 文件类工具的内核路径规范化与防逃逸文件读写工具看起来简单无非是fs.readFile和fs.writeFile但A助手在它们前面包了一层非常严谨的路径校验逻辑。原因很简单模型可能因为推理错误、上下文误导甚至恶意提示词试图读取项目目录以外的敏感文件。如果这种请求被执行后果不堪设想。路径校验的核心函数是normalizePath我把它简化后在注释里写了详细说明import path from node:path; import fs from node:fs; export function normalizePath(requestedPath, workspaceRoot) { const expanded requestedPath.startsWith(~) ? path.join(os.homedir(), requestedPath.slice(1)) : requestedPath; const absolute path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(workspaceRoot, expanded); const rootReal fs.realpathSync(workspaceRoot); const targetReal fs.realpathSync(absolute); const rel path.relative(rootReal, targetReal); if (rel.startsWith(..) || path.isAbsolute(rel)) { throw new Error(path escapes workspace: ${requestedPath}); } return targetReal; }这个函数做了四件事把~展开成用户主目录、把相对路径解析成绝对路径、用realpathSync把符号链接解析到真实路径、再用path.relative判断目标是否仍然位于工作区内。第四步是关键中的关键。如果只用path.resolve做前缀判断那么/project/link-to-etc/passwd这种路径会轻松绕过检查因为link-to-etc是一个指向/etc的符号链接。只有realpathSync之后再做比较才能真正确保安全。我把这段逻辑称为“文件工具的守门员”。在实际调试时我曾经去掉realpathSync测试发现一个指向外部目录的软链就能让工具读到工作区外的文件。这个坑我在第4章还会专门复盘。如果你自己实现一个类似工具强烈建议原样继承这个四段式校验过程它能帮你避开大部分路径安全问题。3.2 命令执行与权限审批approve机制落在哪一层命令执行工具在三类工具里权限最重因为run_command一旦放开相当于给了模型一个完整的shell。A助手没有做复杂的命令白名单分类而是把所有命令执行都默认列入“需要用户确认”。这就是你在终端里经常看到的那种“是否允许此命令[y/N/a]”提示的来源。权限审批模块的接口设计得特别清晰export class PermissionManager { constructor(rules [], config {}) { this.rules rules; this.alwaysAllow new Set(config.alwaysAllow || []); this.alwaysDeny new Set(config.alwaysDeny || []); } async check(toolCall, context) { const key ${toolCall.name}:${JSON.stringify(toolCall.arguments)}; if (this.alwaysDeny.has(key)) { return deny; } if (this.alwaysAllow.has(key)) { return allow; } for (const rule of this.rules) { if (rule.match(toolCall, context)) { return rule.action(toolCall, context); } } return ask; } }默认策略是“ask”即询问用户。当用户对某个命令选择了“always”始终允许时该命令的参数签名会被写入alwaysAllow集合如果选择了拒绝则写入alwaysDeny集合。这种白名单和黑名单并存的设计看起来简单但实用性很强。黑名单优先级高于白名单意味着即使你之前把某类命令设成始终允许也能通过一条精确条目的黑名单把它按下去避免了规则之间的意外覆盖。在终端交互实现上询问过程会渲染出一个带编号的工具调用列表用户按y允许、n拒绝、a总是允许、d总是拒绝。这样的交互设计比让用户输入“yes/no”高效得多我见过不少AI编程工具在权限确认环节设计得极其繁琐用几秒时间敲一长串命令确认体验非常差。A助手这个设计值得直接抄。3.3 上下文收集器为什么grep结果要自己做截断搜索类工具相对安全但A助手对它们的输出做了严格控量。原因很朴素一个仓库里grep一个常见关键字可能返回几千条匹配全塞进上下文再大的上下文窗口也扛不住。A助手为此实现了一个context/collector.js专门负责合并和压缩工具结果。截断策略不是简单地从头部截取N行而是“两头保留、中间折叠”。源码里的处理逻辑大概是这样的export function truncateToolOutput(content, maxChars 12000) { if (content.length maxChars) { return content; } const headCount Math.floor(maxChars * 0.6); const tailCount maxChars - headCount - 80; const head content.slice(0, headCount); const tail content.slice(content.length - tailCount); const folded \n...[truncated ${content.length - headCount - tailCount} chars]...\n; return head folded tail; }这个“金分割”式的比例分配很有意思保留前60%的头部内容再保留尾部的一部分中间用一行说明折叠了多少。头部门是匹配内容最集中的区域尾部则可能包含总结性输出。折叠行本身也占了80个字符不会让模型误解为原文的一部分。为什么要保留尾部这是我一开始没想通的。后来看注释才明白很多命令执行结果比如git diff统计末尾有总结性数字像“3 files changed, 15 insertions”。直接把尾巴扔掉模型就会缺失这部分关键结论信息。这个经验对我后面的工具设计帮助很大。我还会特别留意grep工具的输出格式。A助手给每条匹配都加上了文件:行号:内容的前缀这看起来是小事但让模型能精准定位代码位置。一旦模型要在后续工具调用中读取某一行它可以直接复用这个地址。工具输出格式的设计本质上就是在给模型提供结构化可消费的信息这个理念贯穿整个工具层。4. 踩坑实录我在还原过程中被三个细节卡住的完整排查链路这个章节是全文最有“现场感”的部分。读源码和调试源码是两码事下面三个问题都是我在还原和二次开发时真实卡过的坑。我没有直接给结论先把排查链路写出来因为排查思路本身比最终答案更值钱。4.1 符号链接引发的路径“越狱”问题第一次发现这个问题是在我给A助手加自定义工具的时候。我写了一个新的read_file变体没走它的normalizePath而是用了一个简单的path.resolve判断就放行了。测试的时候建了一个测试目录在项目里放了一个指向项目外敏感目录的符号链接然后让模型去读那个链接路径。原本预期是这里应该报错结果它真的把外部文件内容返回了。我当时的排查链路是这样的。第一先在normalizePath入口处加了断点单步执行跟踪所有临时变量发现传入的相对路径被正确解析到了链接的目标绝对路径path.relative判断结果也没有越界。第二我意识到问题不在resolve而在于最终解析出的路径是否真的是工作区内的实体路径于是把fs.realpathSync分别加在工作区和目标文件上打印两者对比发现工作区路径和链接目标经过realpath后相对关系立刻变成了../../secret越界问题当场暴露。第三我回头去看A助手源码确认它的原版实现里早就包含了realpath这一步问题出在我删掉了它。这个坑给我一个非常深刻的教训路径安全的所有校验必须作用在“最终物理路径”上而不是“用户输入的路径”上。用户输入可以绕相对路径可以绕符号链接可以绕但realpathSync之后得到的是一个实打实的文件系统位置再拿它做比较几乎无解可绕。后来我写任何涉及文件系统的工具都会默认保留这个双realpath校验步骤哪怕团队里其他人觉得它“多此一举”。这类问题的排查还有个技巧尽量用最小复现用例不要直接拿真实项目测试。我把一个测试目录压缩到只有一个软链和两个文本文件问题立刻清晰起来。4.2 工具输出太长导致模型对话窗口被撑爆第二个坑在跑长任务的时候出现。我给A助手接了内部的一个静态检查脚本让模型检查整个仓库的代码规范。跑了大概20轮后模型开始疯狂重复“让我再看看这个文件”然后不断输出互相重叠的工具调用完整逻辑完全乱掉。我看控制台日志发现上下文窗口剩余token量已经极低了大部分空间被前几轮的工具结果占据。排查链路是这样走的。第一步我开启DEBUG日志观察每一轮请求的token估算值发现从第15轮开始每次请求都稳定撞在窗口上限。第二步我把发送给模型的完整messages序列dump到文件里用脚本统计各条消息的token占比发现排名前列的全是tool角色的消息而且某些单条tool消息本身就占了几千token。第三步我意识到自己的输出没有经过A助手的截断处理我在fork源码时为了调试方便把truncateToolOutput临时改成了一个直接返回原内容的版本后面忘了改回来。修复方案很简单恢复截断函数同时把截断参数从默认的12000字符调到8000字符给高频工具的结果格式加了更紧凑的序列化。还有一个额外的收获工具结果应该尽量做结构化压缩比如对grep结果可以只保留前30条匹配并附加统计数据这样模型既能看到“全貌”又不用被几千行无关内容淹没。这个坑教会我Agent系统的很多“异常归因到模型能力不行”的问题根子其实在上下文管理不当上。模型再强也经不起你把一堆没用的中间产物堆到它眼前。4.3 并发执行时工作目录错乱第三个坑最隐蔽也最有趣。A助手的命令执行工具在默认实现里是串行的但我做二次开发时加了一个并行执行多个命令的能力想着提升性能。测试很快暴露问题两个并行任务中有一个命令明明应该写在项目subdirA里结果跑到了subdirB下。我排查的第一步是给两个并行任务都加上日志打印它们执行时的process.cwd()发现打印结果一样都指向了同一个全局目录。第二步是看代码里spawn命令的地方发现执行函数是这样写的const resolvedCwd path.resolve(context.workspaceRoot, task.cwd); child_process.spawn(cmd, args, { cwd: resolvedCwd, shell: true });看起来没问题每个任务都指定了cwd。但问题出在更早的地方在命令执行之前有一段逻辑会把当前目录缓存在一个全局变量里用来处理某些没有显式传cwd的子命令。两个并行任务共享了这个变量任务A写进去的cwd还没被消费任务B就把它覆盖了。修复办法是把所有依赖全局变量的状态全部消灭统一改成显式传参。context对象在每个任务里都是独立的命令执行的cwd一律从task.cwd读取不再在函数之间用共享状态传递。这类“异步并发下的共享状态污染”问题在Node.js里非常经典因为事件循环是单线程的共享变量的读写顺序完全取决于异步调度的时机肉眼很难发现。排查时最有效的手段就是给共享变量加写入日志看它到底被谁在什么时间点改掉了。这个坑让我对Agent系统的并发设计多了几分敬畏。工具层看似简单但一旦涉及多个异步任务任何共享可变状态都会成为定时炸弹。写这类系统时我现在的铁律是能传参就不存全局能不可变就不重建能用局部变量就不用模块级变量。5. 二次开发建议哪些地方可以动、怎么动才不翻车源码还原的终点通常是二次开发。A助手这类架构的扩展性不错但也不是所有地方都适合随便改。我把经验浓缩成三块最小可改清单、Mock模型闭环测试、自定义权限规则。照着这个顺序下手能最大程度避免改坏核心循环。5.1 最小可改清单从日志到自定义工具三步走如果你拿到这份源码只想做最小的定制我建议先别碰Agent主循环和权限系统从这三处入手。第一处是日志级别。源码里已经内置了一套基于环境变量的日志开关你可以在自己的工具里沿用这套机制。第二处是系统提示词的模板文件。A助手把系统提示词模板独立放在一个文件里改它会直接影响模型行为但对代码结构零侵入。第三处是自定义工具注册。在dispatchMap里追加一行映射再实现一个符合工具接口的类就行。自定义工具接口要满足三个约定提供name、提供description、实现execute(args, context)。下面是一个最简单的自定义工具示例用于把当前Agent的完整对话历史写到工作区里方便调试import fs from node:fs/promises; import path from node:path; export const dumpHistoryTool { name: dump_history, description: Dump the current conversation history to workspace/history.json, async execute(args, context) { const target path.join(context.workspaceRoot, history.json); const data JSON.stringify(context.messages, null, 2); await fs.writeFile(target, data, utf8); return history written to ${target} (${data.length} chars); }, };注册后立刻生效模型会在系统提示词里看到这个新工具。不过要注意系统提示词里的工具描述是从Schema自动生成的如果你新工具的描述写得含糊模型很可能用错参数。我第一次写的工具description只有一句话结果模型连续三次传错参数后来我把参数约束写成清晰的自然语言才稳定下来。5.2 做一个Mock模型闭环测试Agent循环改动核心循环时最怕的就是拿真实模型一遍遍试错又慢又费钱。A助手的架构里model是一个抽象接口意味着你可以用一个Mock实现完全替换真实模型在本地跑闭环测试。Mock模型的核心职责是根据当前输入返回一个预先编排好的响应序列。比如你想测试“模型连续调用两次工具后完成”的场景就写一个只响应两轮的Mockconst fakeResponses [ tool_call{name:read_file,arguments:{path:package.json}}/tool_call, 我已经读到了 package.json项目入口是 bin/acli.js。, ]; export const mockModel { async complete(messages) { const callCount messages.filter((m) m.role tool).length; const index Math.min(callCount, fakeResponses.length - 1); return fakeResponses[index]; }, };这个Mock逻辑很简单但它能帮你测试很多真实场景如果第一轮工具调用返回报错模型下一轮会不会调整策略如果模型连续三轮调用同一个工具循环会不会被lastToolSignature中断用这套Mock我在本地几分钟就能跑完原来需要真实模型跑半小时的测试路径。写Agent相关代码时我强烈建议把“模型必须可Mock”当作一个基本架构要求没有这个抽象调试会非常痛苦。5.3 给权限系统加一套自定义规则权限系统的默认策略是“ask”但如果接入方希望某些命令直接放行、某些命令坚决拒绝可以在配置里写自定义规则。A助手的规则接口是这样的const customRules [ { match: (toolCall) toolCall.name run_command /^(git status|git diff|ls|cat)/.test(toolCall.arguments.command), action: () allow, }, { match: (toolCall) toolCall.name run_command /rm -rf/.test(toolCall.arguments.command), action: () deny, }, ];把这份数组传给PermissionManager的构造函数规则就被注入了。我通常会把“读操作放行、写操作询问、危险操作拒绝”作为默认基线再在具体项目里加颗粒度更细的规则。比如某个项目中只有src/目录能写那write_file工具的参数路径如果不在src/下就直接deny不用每次都问用户。自定义权限规则要注意优先级。A助手的规则是按数组顺序遍历的先匹配到的先生效。所以如果你有一条宽泛的放行规则写在前面后面再写精确的拒绝规则也不会触发因为前面已经return了。我踩过一次这个坑后来在代码注释里特地标注了“规则顺序敏感”并建议把最严格的规则放在最前面。这个设计本身没有对错但你必须清楚它是顺序敏感的不然一定会写出方向完全反了的权限策略。最后说一点体感上的经验。我做源码还原从来不是为了“把每一行都读懂”而是为了“在需要改它的时候能精准下手”。你跟着这篇文章把这些模块过一遍之后其实已经比很多直接使用AI助手的人高一层了——你知道模型为什么能看到文件内容、命令为什么需要确认、上下文窗口为什么会慢慢占满。碰上奇怪问题时你不只会抱怨“它怎么这么笨”还能打开日志、翻一眼源码推断出问题大概率出在哪个环节这种掌控感才是源码还原真正带给你的东西。
阅读完成 · 觉得有帮助?
咨询建站