有个读者朋友在后台留言,点名让我写一篇“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里。我读这个文件时会重点留意三件事:参数解析、配置加载、REPL(Read-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模型的核心职责是:根据当前输入返回一个预先编排好的响应序列。比如你想测试“模型连续调用两次工具后完成”的场景,就写一个只响应两轮的Mock:
const 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助手的人高一层了——你知道模型为什么能看到文件内容、命令为什么需要确认、上下文窗口为什么会慢慢占满。碰上奇怪问题时,你不只会抱怨“它怎么这么笨”,还能打开日志、翻一眼源码,推断出问题大概率出在哪个环节,这种掌控感才是源码还原真正带给你的东西。