昨天刚和一些朋友聊了一下 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_files、read_file、search_files 和 run_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"
}));
}
需要忽略 .git、node_modules、dist、大型二进制目录,否则模型会收到大量无用信息。
read_file:只读取当前需要的代码
相比每次返回完整文件,支持 startLine 和 endLine 更实用。搜索工具先返回匹配行,模型再读取匹配位置前后的几十行。
工具结果最好带上行号:
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 |
system 或 developer 消息的优先级高于普通 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_call 和 function_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 中写"你是高级架构师"不会增加知识;把目标、上下文、约束、失败处理和完成标准说清楚,通常更有用。