AI 为什么会越改越坏?5个Tools 拆解编程 Agent

昨天刚和一些朋友聊了一下 AI 编程,有人说:"怎么感觉最近 AI 降智了?有时候让它修一个问题,问题没有修好,反而把原本正常的代码改坏了。"

这种情况很多人都遇到过。明明只是想改一个按钮,最后牵连了状态管理;让它修一个接口报错,它却顺手改了返回结构。表面上看,像是模型突然变笨了。

但实际上不能简单的把原因归结为模型能力下降。一次修改为什么会跑偏,可能与模型有关,也可能是 Agent 没有拿到正确的代码、搜索结果不完整,或者它在某一轮把错误的工具结果当成了事实。

然后我就分享了一下,一个编程 Agent 大概是怎么运行的。把搜索、读取、修改、测试和消息传递的过程拆开后,才容易看懂"它为什么会改错",也知道应该从哪里限制和修正。

所以大家在使用 AI 的过程中不能只知其然,也要知其所以然。

为了把这个过程讲的具体一点,我用 TypeScript 写了一个最小命令行 Agent。它可以回答项目代码问题,也可以在用户批准后修改文件。我给它准备了五个工具:

text 复制代码
list_files
read_file
search_files
apply_patch
run_tests

接下来就沿着这条执行链路往下看:工具由谁执行,Agent 如何从搜索结果定位到具体代码,以及模型为什么需要区分不同来源的消息。

Agent 是怎样执行任务的

普通的大模型调用通常只有一次请求和一次回答:

text 复制代码
用户问题 -> 模型 -> 文本回答

编码 Agent 多了工具调用和循环:

text 复制代码
用户任务
  -> 模型决定下一步
  -> 调用工具
  -> 工具返回结果
  -> 模型根据新结果继续决定
  -> 完成或失败

例如,我给 Agent 一个任务:

找出购买按钮点击无反应的原因,修复后运行测试。

它不应该直接猜答案,而是可能依次执行:

text 复制代码
list_files("src")
search_files("PurchaseButton")
read_file("src/components/PurchaseButton.tsx")
search_files("openPurchaseDialog")
read_file("src/stores/dialogStore.ts")
apply_patch(...)
run_tests("npm test")

每次工具返回的结果,都会成为下一轮模型调用的上下文。模型并不是一开始就知道整个项目,而是在执行过程中逐步收集信息。

最小循环可以写成这样:

typescript 复制代码
const MAX_LOOPS = 10;

for (let loop = 0; loop < MAX_LOOPS; loop++) {
  const response = await callModel({ messages, tools });

  if (response.finalAnswer && response.toolCalls.length === 0) {
    return response.finalAnswer;
  }

  for (const toolCall of response.toolCalls) {
    const result = await executeTool(toolCall);

    messages.push({
      role: "tool",
      tool_call_id: toolCall.id,
      content: JSON.stringify(result)
    });
  }
}

throw new Error("达到最大模型循环次数,任务仍未完成");

这段代码就是最小 Agent Loop:模型决策、工具执行、结果回传,然后继续下一轮。

Tool 不是模型直接执行的一段代码

read_file 为例,我们先把工具说明交给模型:

typescript 复制代码
const readFileTool = {
  type: "function",
  name: "read_file",
  description: "读取当前项目中的文本文件,可指定起止行",
  parameters: {
    type: "object",
    properties: {
      filePath: { type: "string" },
      startLine: { type: "number" },
      endLine: { type: "number" }
    },
    required: ["filePath"]
  }
};

模型拿到的是工具名称、用途和参数格式。需要读取文件时,它会返回类似这样的工具请求:

json 复制代码
{
  "id": "call_01",
  "name": "read_file",
  "arguments": {
    "filePath": "src/components/PurchaseButton.tsx",
    "startLine": 1,
    "endLine": 120
  }
}

到这里,文件还没有被读取。真正读取文件的是 Agent 所在的程序:

typescript 复制代码
const readFileInput = z.object({
  filePath: z.string(),
  startLine: z.number().int().positive().optional(),
  endLine: z.number().int().positive().optional()
});

async function readFile(rawInput: unknown) {
  const input = readFileInput.parse(rawInput);
  const target = resolveProjectPath(input.filePath);
  const content = await fs.readFile(target, "utf8");
  const lines = content.split(/\r?\n/);

  const start = (input.startLine ?? 1) - 1;
  const end = input.endLine ?? lines.length;

  return lines
    .slice(start, end)
    .map((line, index) => `${start + index + 1}: ${line}`)
    .join("\n");
}

这里有一个很重要的职责划分:

text 复制代码
模型:决定要调用哪个工具,并生成参数
Agent 运行时:校验参数、检查权限、调用工具、记录日志
工具:真正读取文件、搜索内容、修改代码或运行测试

模型发出的是"行动请求",不是直接获得了操作系统权限。

工具都需要自己实现吗

这要看工具来自哪里。OpenAI Responses API 把工具分成三类:平台内置工具、MCP 工具和自定义函数工具。

平台内置工具

Web Search、File Search、Code Interpreter 这类通用能力,可以由模型平台托管。我们在请求中直接启用工具,平台负责它的通用执行过程。

比如 Web Search 既可以使用平台内置版本:

typescript 复制代码
const response = await openai.responses.create({
  model: "支持 Web Search 的模型",
  tools: [{ type: "web_search" }],
  input: "查询这个框架最近一次版本更新"
});

也可以自己封装搜索服务:

typescript 复制代码
const webSearch = tool({
  name: "web_search",
  schema: z.object({ query: z.string().min(1) }),
  execute: async ({ query }) => mySearchClient.search(query)
});

两种方式都叫工具调用,但执行方不同。

MCP 工具

GitHub、数据库、浏览器都可以通过 MCP Server 暴露工具。Agent 通过统一协议发现和调用这些能力,不必把所有集成都写进自己的主程序。

MCP 解决的是工具接入方式,不会替我们定义业务权限。例如一个数据库 MCP 提供了 execute_sql,Agent 运行时仍然要决定:它是否只允许查询、能访问哪些表、写操作要不要审批。

自定义函数工具

业务工具通常由我们自己实现,例如:

  • 查询订单;
  • 创建工单;
  • 读取本地项目文件;
  • 调用项目测试脚本;
  • 修改公司内部系统的数据。

在我这个 TypeScript 编码 Agent 中,list_filesread_filesearch_filesrun_tests 都可以直接由本地程序实现。apply_patch 可以使用平台支持的补丁工具协议,也可以自己接入成熟的 diff/patch 库,但本地文件权限、审批和结果验证仍然由运行时控制。

关于你使用的模型内置了哪些工具,可以去查看对应模型页面的 Tools 区域:

我找的就是 gpt-6-astra 模型的Tools,这里要特别区分一下 Hosted Shell 和 Local Shell。Hosted Shell 是在平台提供的环境中运行;如果我要操作用户电脑上的真实项目,就必须由本地 Agent 或 Harness 提供工作目录、权限和执行环境。

五个工具分别怎么实现

list_files:先让模型看见项目轮廓

它不需要一开始递归返回几万个文件。先列当前目录,模型再决定进入哪个子目录,通常更节省上下文。

typescript 复制代码
async function listFiles(directory = ".") {
  const target = resolveProjectPath(directory);
  const entries = await fs.readdir(target, { withFileTypes: true });

  return entries.map((entry) => ({
    name: entry.name,
    type: entry.isDirectory() ? "directory" : "file"
  }));
}

需要忽略 .gitnode_modulesdist、大型二进制目录,否则模型会收到大量无用信息。

read_file:只读取当前需要的代码

相比每次返回完整文件,支持 startLineendLine 更实用。搜索工具先返回匹配行,模型再读取匹配位置前后的几十行。

工具结果最好带上行号:

text 复制代码
21: export function PurchaseButton() {
22:   const openPurchaseDialog = useDialogStore(...)
23:
24:   return <button onClick={() => openPurchaseDialog}>购买</button>
25: }

模型生成补丁时就能更准确地描述位置,日志也更容易阅读。

search_files:编码 Agent 的定位入口

最小版本可以用 Node.js 遍历文本文件,也可以直接调用 rg

powershell 复制代码
rg -n --glob '!node_modules/**' --glob '!dist/**' "openPurchaseDialog" src

返回结果不要只有文件名,最好包括行号和少量上下文:

json 复制代码
{
  "matches": [
    {
      "file": "src/components/PurchaseButton.tsx",
      "line": 24,
      "text": "onClick={() => openPurchaseDialog}"
    }
  ]
}

apply_patch:写文件前先经过确定性检查

在生产环境中更建议大家使用 unified diff 和成熟的 patch parser。

执行顺序应该是:

text 复制代码
解析补丁
  -> 校验目标路径
  -> 确认补丁匹配当前文件
  -> 展示变更
  -> 请求用户批准
  -> 写入文件
  -> 返回实际 diff

审批不能只靠 Prompt。即使系统消息写了"修改前必须征得用户同意",工具执行器也必须在代码层阻止未批准的写入。

typescript 复制代码
async function applyPatch(input: ApplyPatchInput) {
  const target = resolveProjectPath(input.filePath);
  const approved = await requestApproval({
    action: "apply_patch",
    filePath: input.filePath,
    diff: input.patch
  });

  if (!approved) {
    return { ok: false, error: "用户拒绝了文件修改" };
  }

  const result = await patchService.apply(target, input.patch);
  return { ok: true, diff: result.diff };
}

run_tests:让完成结论有外部证据

模型认为代码正确,不代表代码真的正确。测试工具至少要返回退出码、标准输出、错误输出和是否超时。

typescript 复制代码
async function runTests(command: string) {
  try {
    const result = await exec(command, {
      cwd: PROJECT_ROOT,
      timeout: 120_000
    });

    return {
      ok: true,
      exitCode: 0,
      stdout: result.stdout.slice(-4000),
      stderr: result.stderr.slice(-4000)
    };
  } catch (error) {
    return {
      ok: false,
      exitCode: getExitCode(error),
      error: getErrorMessage(error)
    };
  }
}

系统规则可以要求:只要调用过 apply_patch,最终结束前就必须存在一次成功的测试结果。这个条件最好同时在代码里检查,而不是完全相信模型自觉遵守。

编程 Agent 如何定位到具体逻辑

编程 Agent 通常不会把整个代码库一次性发给模型。它就像开发者排查问题时的操作,只是搜索和阅读步骤由模型动态决定。

还是用"购买按钮点击无反应"举例。

第一步:找到候选入口

模型可能先搜索页面文案、组件名或事件名:

text 复制代码
search_files("购买")
search_files("PurchaseButton")
search_files("onClick")

搜索工具给出几个候选文件后,模型读取相关片段。

第二步:沿调用关系继续查

假设它读到:

tsx 复制代码
<button onClick={() => openPurchaseDialog}>购买</button>

模型发现 openPurchaseDialog 可能没有真正执行,但还不能马上修改。它可以继续搜索函数定义,确认调用方式和返回值:

text 复制代码
search_files("function openPurchaseDialog")
search_files("openPurchaseDialog:")
search_files("openPurchaseDialog(")

如果文本搜索不够,还可以接入 AST 或 LSP:

  • AST 用来找函数定义、导入关系和语法节点;
  • LSP 用来跳转定义、查找引用和读取类型错误;
  • Git 用来查看历史改动和当前 diff;
  • 测试、编译器和浏览器用来验证推断。

第三步:生成最小补丁

确认函数应该直接调用后,模型生成修改:

diff 复制代码
- <button onClick={() => openPurchaseDialog}>购买</button>
+ <button onClick={() => openPurchaseDialog()}>购买</button>

第四步:验证,而不是直接宣布完成

补丁应用后调用测试。如果测试失败,错误输出再次进入消息上下文:

text 复制代码
tool: run_tests failed
TypeError: openPurchaseDialog is not a function

模型看到新的观察结果后,会重新读取 store 类型、检查 Hook 返回值,再决定是否继续修改。

因此,所谓"编码 Agent 能定位代码",不是模型一次猜中某一行,而是下面这条反馈链:

text 复制代码
搜索候选
  -> 读取局部代码
  -> 沿定义和引用继续搜索
  -> 形成修改假设
  -> 应用最小补丁
  -> 用测试结果验证
  -> 失败则继续循环

rg、AST、LSP、Git 和测试框架都是传统开发工具。而模型的作用,是根据当前观察结果决定下一步用哪个工具。

消息里的 role,和提示词角色是一回事吗?

前面的循环里已经出现了 role: "tool"。昨天我的朋友也问我,有时候提示词里会先给 AI 一个身份,比如'你是一名高级架构师'。这里的 role 是不是也在做同一件事?

这个问题刚好把两个容易混淆的概念放到了一起:Prompt 内容里的角色扮演,以及消息协议里的 role

为了让模型知道应用规则和用户目标,我们通常会先初始化消息:

typescript 复制代码
const messages: ChatMessage[] = [
  {
    role: "system",
    content: "先检查再修改;修改后必须运行测试。"
  },
  {
    role: "user",
    content: goal
  }
];

这里的 role 不是让模型扮演某种人物,而是消息协议的一部分,用来标记消息的来源和职责。

Role 代表什么 在编码 Agent 中的例子
system / developer 应用规则和行为边界 只能操作项目目录,修改后必须测试
user 本次任务目标 修复购买按钮点击无反应
assistant 模型之前的回答或工具请求 请求 search_files 搜索组件
tool 外部工具的真实返回 找到 PurchaseButton.tsx:24

systemdeveloper 消息的优先级高于普通 user 消息;不同模型对两者的支持方式可能不同,要以具体 API 为准。

模型请求调用工具时,会产生一条 assistant 消息:

json 复制代码
{
  "role": "assistant",
  "tool_calls": [
    {
      "id": "call_01",
      "name": "search_files",
      "arguments": { "query": "PurchaseButton" }
    }
  ]
}

运行时执行后,再写回一条 tool 消息:

json 复制代码
{
  "role": "tool",
  "tool_call_id": "call_01",
  "content": { "matches": ["src/components/PurchaseButton.tsx:24"] }
}

tool_call_id 把结果和之前的调用对应起来。完整过程就是:

text 复制代码
developer:修改后必须运行测试
user:修复购买按钮无响应
assistant:请求 search_files
tool:返回文件和行号
assistant:请求 read_file
tool:返回代码片段
assistant:请求 apply_patch
tool:补丁应用成功
assistant:请求 run_tests
tool:测试通过

如果把这些内容拼成一大段字符串,模型就更难区分用户目标、自己的历史决定和工具事实。上面的结构接近 Chat Completions;而 Responses API 通常用 function_callfunction_call_output 表示调用与结果,语义是相同的。

为什么只靠 Prompt 还不够

前面已经看到,Prompt 可以告诉模型"先检查、再修改、最后测试"。但如果这些要求只停留在 Prompt 里,模型仍然可能请求越界路径、跳过审批,或者在测试没通过时说"完成了"。真正的约束必须落在运行时代码中。

一个更接近实际使用的 runAgent 可以这样组织:

typescript 复制代码
async function runAgent(goal: string) {
  const state = {
    status: "running" as const,
    changedFiles: [] as string[],
    testPassed: false
  };

  const messages: ChatMessage[] = [
    {
      role: "developer",
      content: [
        "先读取和搜索,再提出修改。",
        "写文件前必须获得审批。",
        "发生文件变更后,必须通过测试才能完成。"
      ].join("\n")
    },
    { role: "user", content: goal }
  ];

  for (let loop = 0; loop < 10; loop++) {
    log("model.start", { loop });

    const response = await model.respond({ messages, tools });
    messages.push(response.assistantMessage);

    if (response.toolCalls.length === 0) {
      if (state.changedFiles.length > 0 && !state.testPassed) {
        throw new Error("代码已经修改,但没有成功的测试证据");
      }

      log("agent.completed", { loop });
      return response.text;
    }

    for (const call of response.toolCalls) {
      log("tool.start", { name: call.name, args: call.arguments });

      const result = await toolRegistry.execute(call);

      if (call.name === "apply_patch" && result.ok) {
        state.changedFiles.push(result.filePath);
        state.testPassed = false;
      }

      if (call.name === "run_tests") {
        state.testPassed = result.ok && result.exitCode === 0;
      }

      messages.push({
        role: "tool",
        tool_call_id: call.id,
        content: JSON.stringify(result)
      });

      log("tool.finish", { name: call.name, result });
    }
  }

  throw new Error("达到 10 次模型循环上限,任务未完成");
}

这段代码里,模型只负责动态决策。最大循环次数、参数校验、路径安全、审批、日志和完成条件都由运行时负责。

也可以把它理解成一个很小的 Harness:

text 复制代码
模型
  + 消息历史
  + 工具注册表
  + 执行循环
  + 权限与审批
  + 状态与日志
  + 完成条件

回到"AI 降智":到底可能是哪一环出了问题

理解完整链路后,再看"越改越坏"就不只是一个模糊感受了。模型能力变化只是可能原因之一,很多问题其实发生在模型之外。

表面现象 更值得检查的环节
找错文件、答非所问 搜索范围是否正确,工具是否返回了足够的文件名、行号和上下文
修改了不相关逻辑 用户目标和禁止修改范围是否写清楚,补丁工具是否限制最小变更
把原本正常的代码改坏 模型读取的是不是当前版本,补丁应用前是否校验原文,是否展示 diff 并审批
声称修复但项目跑不起来 是否真正执行了测试或构建,运行时有没有检查退出码
前后判断互相矛盾 工具结果是否正确写回消息历史,上下文裁剪是否丢掉了关键事实
不停修改、迟迟不结束 是否设置最大循环次数、停止条件和明确的完成证据

例如,Agent 搜索 openPurchaseDialog 时只拿到了一个旧测试文件,没有读到真实组件。后面即使模型推理过程看起来合理,也是在错误上下文上继续推理。

反过来,如果工具找对了文件、补丁保持最小、写入需要审批、修改后必须通过测试,即使模型偶尔判断错误,错误也更容易在真正破坏代码之前被拦住。

最后总结

第一,Tool Calling 不等于模型直接执行代码。模型生成调用请求,真正的执行发生在平台或 Agent 运行时。

第二,工具不一定全部自己实现。平台内置工具、MCP 工具和自定义函数工具可以同时存在,但权限、审批和业务边界仍然需要运行时控制。

第三,编程 Agent 定位代码依靠的是搜索、局部读取、定义与引用分析、补丁和测试组成的反馈循环。模型负责决定下一步,传统开发工具负责提供真实信息。

第四,消息中的 role 是协议和指令层级,不是人格设定。平时在 Prompt 中写"你是高级架构师"不会增加知识;把目标、上下文、约束、失败处理和完成标准说清楚,通常更有用。

相关推荐
王国强20091 小时前
OpenCode 深度使用指南
agent
这就是佬们吗1 小时前
不写Prompt,写Loop:AI编程的下一场范式迁移
人工智能·prompt·ai编程
ovO1 小时前
DeepSeek Harness 源码解读(九):它适合什么场景,应该从哪里扩展
开源·agent
OpenTiny社区1 小时前
太酷了!装上OpenTiny dsh‑genui这个插件,你的DeepSeek Harness点击就能干活了!
前端·ai编程
ovO1 小时前
DeepSeek Harness 源码解读(七):会话日志为何是唯一真相源
开源·agent
武子康1 小时前
机器人策略 90% 与 92%:为什么两个百分点通常不足以证明更
人工智能·llm·agent
ovO1 小时前
DeepSeek Harness 源码解读(十):六条设计纪律如何约束可替换运行时
开源·agent
ovO1 小时前
DeepSeek Harness 源码解读(八):文件、命令、审批与沙箱如何协作
开源·agent
阿里云云原生1 小时前
经验自进化:自动挖掘经验资产,消融实验验证真实收益丨AgentLoop 数据飞轮实践(五)
agent