综合实战:用 200 行原生 JS 组装一个全栈自主研发 Coding Agent

本节目标

为什么 Coding Agent 不用「整文件覆写」而是用「精确块替换(Search & Replace)」?

在开发软件工程 Agent 时,新手常给模型提供一个 writeFile(path, fullContent) 工具。这在超过 300 行的代码文件上会导致灾难:

  1. 输出 Token 慢且贵:改一行代码却要让模型重新吐出 500 行未修改的代码。
  2. 偷懒省略损坏源码:模型极易写出 // ... 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),从状态机层面保证了任何未通过测试验证的修改绝不可能提前交差。