综合实战:用 200 行原生 JS 组装一个全栈自主研发 Coding Agent
本节目标
- 将前 14 课的模块(流式传输、Schema 校验、ToolRegistry、ReAct 防死循环、记忆压缩、沙箱自愈、HITL 安全门禁)融会贯通
- 组装一个类似 Claude Code / Cursor Agent 核心原理的自主研发 Coding Agent(支持搜索代码、读取文件、精准补丁替换與单测自愈)
- 手写一套可观测链路追踪器(Span Tracer)与确定性离线评测基准(Agent Eval Harness),用量化指标指导 Agent 迭代
为什么 Coding Agent 不用「整文件覆写」而是用「精确块替换(Search & Replace)」?
在开发软件工程 Agent 时,新手常给模型提供一个 writeFile(path, fullContent) 工具。这在超过 300 行的代码文件上会导致灾难:
- 输出 Token 慢且贵:改一行代码却要让模型重新吐出 500 行未修改的代码。
- 偷懒省略损坏源码:模型极易写出
// ... existing code ...把原有的业务逻辑全部删光。
工业级 Coding Agent(如 Claude Code、Aider)的核心编辑工具是 replaceInFile(path, targetSnippet, replacementSnippet):要求模型只提供需要修改的局部上下文块,如果 targetSnippet 在文件中未命中或命中多处,立即返回精确报错让模型先 viewFile 确认行内容。
完整组装:带链路追踪与评测套件的自主 Coding Agent
export class AutonomousCodingAgent {
constructor({ llm, vfs, testRunner, maxSteps = 12 }) {
this.llm = llm
this.vfs = vfs
this.testRunner = testRunner
this.maxSteps = maxSteps
this.spans = []
}
#recordSpan(name, startMs, meta = {}) {
this.spans.push({
name,
durationMs: Date.now() - startMs,
timestamp: new Date().toISOString(),
...meta
})
}
// 构建研发 Agent 的四大黄金工具集:列目录、读文件、精准替换补丁、跑测试
#buildCodingTools() {
return {
list_files: {
description: '列出工作区中的所有代码文件路径',
parameters: { type: 'object', properties: {} },
execute: async () => this.vfs.listFiles()
},
view_file: {
description: '读取指定文件的完整源码内容',
parameters: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path']
},
execute: async ({ path }) => this.vfs.readFile(path)
},
replace_in_file: {
description: '在指定文件中将唯一的 target 文本块精准替换为 replacement,严禁使用省略号',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
target: { type: 'string' },
replacement: { type: 'string' }
},
required: ['path', 'target', 'replacement']
},
execute: async ({ path, target, replacement }) => {
const content = this.vfs.readFile(path)
const occurrences = content.split(target).length - 1
if (occurrences === 0) {
throw new Error(`未在 ${path} 中找到匹配的 target 代码块,请先调用 view_file 核对精确源码`)
}
if (occurrences > 1) {
throw new Error(`target 在 ${path} 中匹配到了 ${occurrences} 处,请附带更多上下行代码以保证唯一性`)
}
const updated = content.replace(target, replacement)
this.vfs.writeFile(path, updated)
return { status: 'patched', path }
}
},
run_tests: {
description: '运行项目单元测试套件并返回通过状态与失败断言详情',
parameters: { type: 'object', properties: {} },
execute: async () => this.testRunner(this.vfs)
}
}
}
async resolveIssue(issueDescription) {
const tools = this.#buildCodingTools()
const messages = [
{
role: 'system',
content: [
'你是一个自主软件工程 Coding Agent。',
'工作准则:1. 修改前必须先调用 view_file 查看源码;2. 使用 replace_in_file 提交最小化补丁;',
'3. 修改后必须调用 run_tests 验证测试全部通过,只有测试 100% 通过后才可结束任务并输出最终总结。'
].join('\n')
},
{ role: 'user', content: issueDescription }
]
let testsVerified = false
for (let step = 1; step <= this.maxSteps; step++) {
const llmStart = Date.now()
const res = await this.llm.chat({
messages,
tools: Object.entries(tools).map(([name, t]) => ({
type: 'function',
function: { name, description: t.description, parameters: t.parameters }
}))
})
this.#recordSpan('llm_step', llmStart, { step })
const msg = res.message
messages.push(msg)
if (!msg.tool_calls || msg.tool_calls.length === 0) {
// 验收守卫:如果模型声称改完了但还没跑过测试,强制打回要求运行 run_tests
if (!testsVerified) {
messages.push({
role: 'user',
content: '系统验收拦截:你尚未成功运行并通过 run_tests 验证,请先调用 run_tests 确认所有单测通过!'
})
continue
}
return { ok: true, steps: step, summary: msg.content, spans: this.spans }
}
for (const tc of msg.tool_calls) {
const tStart = Date.now()
const fnName = tc.function.name
let obs = ''
try {
const args = JSON.parse(tc.function.arguments || '{}')
const out = await tools[fnName].execute(args)
if (fnName === 'run_tests' && out?.passed === true) {
testsVerified = true
}
obs = JSON.stringify({ ok: true, result: out })
} catch (err) {
testsVerified = false
obs = JSON.stringify({ ok: false, error: err.message })
}
this.#recordSpan(`tool:${fnName}`, tStart, { step })
messages.push({ role: 'tool', tool_call_id: tc.id, name: fnName, content: obs })
}
}
return { ok: false, steps: this.maxSteps, summary: '超出最大步数预算', spans: this.spans }
}
}课后练习
练习 1:在 replace_in_file 工具中,为什么要显式检查 occurrences === 0 和 occurrences > 1 并抛出明确错误提示?
答案:如果 occurrences === 0 时静默忽略,模型会误以为补丁已经打上;而如果 occurrences > 1(例如 return null 在文件里出现了 5 次)时直接替换第一处,极易改错函数位置。强制要求 occurrences === 1 能迫使模型在 target 中带上足够的上下文代码行以唯一定位修改点。
练习 2:在 resolveIssue 循环中,为什么当模型不再输出 tool_calls 准备结束任务时,我们要检查 if (!testsVerified) 并注入一条用户消息强制它先调 run_tests?
答案:大模型常有「改完代码就直接宣布大功告成」的惰性倾向(Premature Termination)。通过在代码层维护 testsVerified 状态位(每次修改文件重置为 false,只有 run_tests 返回 passed: true 才置为 true),从状态机层面保证了任何未通过测试验证的修改绝不可能提前交差。