引言
不是带你读某一个 repo,而是把实现思路拆开讲清楚
读完你也能自己搭一个,顺便养一只听话的 Agent 🐣
聊天机器人和编程助手,差在哪呀?
很多人做 AI 助手的第一步是:接个大模型 API,把回复显示出来。恭喜,你已经拥有一个可爱的聊天窗啦 ✨
但编程助手还想再多一点点:它不只会说话,还要会动手——读文件、改代码、创建新文件。
这就引出了 Agent:
| 普通 Chat | Agent | |
|---|---|---|
| 输出 | 纯文字 | 文字 + 工具调用 |
| 能力 | 只能「说」 | 还能「做」 |
| 场景 | 问答、解释 | 重构、批量改文件 |
今天这篇,就用「做一个能改代码的 VS Code 小助手」当主线,把里面值得学的模式都拎出来~
一、先画一张地图:三层小蛋糕 🍰
做 VS Code AI 助手,几乎都会长成这样:
🍰 顶层:Webview(React 聊天界面,好看但没文件系统权限)
↕ postMessage(唯一小桥)
🍰 中层:Extension Host(能调 VS Code API,真正读写文件)
↕ HTTP
🍰 底层:LLM API(大脑,负责想)
为什么要拆开?
- Webview 住在沙箱里,安全但手短
- Extension Host 权限完整,能打开编辑器、读写磁盘
- 所以常见分工是:UI 负责好看,Extension 负责干活
这不是某个项目的奇技淫巧,而是 VS Code 生态里很经典的玩法哦~
二、通信协议:先把「快递单」设计好 📦
两端不能互相 import,只能靠 postMessage。新手最容易写成字符串满天飞,后期改起来会哭。
可复用小技巧:抽一层 shared 协议
- 消息类型常量,确保全局唯一
enum WebviewToExtensionKey {
SendMessage = 'send-message',
PickFiles = 'pick-files',
PauseStreaming = 'pause-streaming',
}
- 消息体类型定义
type SendMessage = {
type: 'send-message';
text: string;
attachments?: FileAttachment[];
};
Extension 和 Webview 共用同一份类型,改协议时 TypeScript 会帮你抓漏——超级贴心 💕
消息大致分两类:
Webview → Extension:用户操作(发消息、选文件、暂停)Extension → Webview:状态推送(新消息、流式 chunk、历史)
经验小贴士: 协议越早定越好。等功能堆成小山再补,很容易同一种事出现三种 message type 😵💫
三、Agent 循环:让模型学会「想 → 做 → 再想」🔄
Agent 的核心,其实是一个很温柔的小循环:
用户消息进来
↓
问 LLM:「要不要用工具?」
├─ 要 → 执行工具 → 结果塞回对话 → 再问一次
└─ 不要 → 输出最终文字 → 收工!✨
伪代码长这样:
for (let step = 0; step < maxSteps; step++) {
const { content, toolCalls } = await completeWithTools({ messages, tools });
// 模型决定调用工具
if (toolCalls?.length) {
// 通知webview工具调用
messages.push({ role: 'assistant', content, tool_calls: toolCalls });
// 遍历调用工具
for (const call of toolCalls) {
// 工具调用结果
const result = await executor.execute(call.name, args);
// 传递给webview
messages.push({ role: 'tool', tool_call_id: call.id, content: result});
}
continue; // 带着结果继续问
}
if (content) return content; // 任务完成啦
}
几个值得学的小心机:
-
一定要有
maxSteps
模型有时候会陷入「读完再读再读」的无限摸鱼,上限是生产级 Agent 的基本礼貌 🐟 -
工具结果必须回传
role: tool让模型基于真实结果继续决策,而不是瞎编「已经改好啦」 -
System Prompt 要和工具一起设计
比如:改已有文件优先edit_file;用户附件路径必须原样使用。Prompt 和工具是一对小情侣,单改一边容易吵架 💑
maxSteps
maxSteps 就是 Agent 这一轮最多能「问 LLM → 调工具」循环几次。
可以把它理解成:防止模型无限摸鱼的刹车 🛑
第 1 轮:问 LLM → 调工具
第 2 轮:带着结果再问 → 再调工具
...
第 N 轮:还没给出最终文字 → 停下来报错
在项目里:
- 配置项:
chatSample.agent.maxSteps - 默认值:
8 - 范围:
1 ~ 20 - 读取位置:
getMaxAgentSteps()→ 传给runAgentLoop
为什么需要它?
模型有时会陷入「读文件 → 再读 → 再读」或反复改同一处,如果没有上限,Agent 可能一直转下去,又慢又费 token。
所以:
maxSteps = 8:最多允许 8 次工具决策轮次- 某轮没有
tool_calls、直接返回content:任务正常结束,不会硬跑满 8 次 - 跑满了还没最终回复:抛出类似
Agent exceeded maximum steps的错误
maxSteps不是「必须执行几次」,而是「最多允许循环几次」。
completeWithTools
Agent 用来「问一次 LLM」的封装函数
可以把它理解成:
带着「对话历史 + 可用工具清单」去问模型,
模型要么回一段文字,要么说「请帮我调用这些工具」。
它做了什么?
- 读取 API Key / model / baseUrl
- 调用 OpenAI 兼容接口
chat.completions.create - 把工具定义
tools一起传给模型(Function Calling) - 返回两个东西:
{
content: string | null, // 模型的文字回复
toolCalls: ... | null // 模型想调用的工具列表
}
和普通聊天有什么区别?
普通聊天 streamChat | completeWithTools | |
|---|---|---|
| 是否带工具 | 否 | 是(tools) |
| 返回内容 | 纯文字(可流式) | content + tool_calls |
| 流式 | 是 | 否(等整次回复) |
| 用途 | 打字机式对话 | Agent 决策一轮 |
tool_choice: "auto" 的意思是:模型自己决定「直接回答」还是「先调工具」。
在 Agent 循环里扮演什么角色?
AgentLoop
└─ completeWithTools() ← 问 LLM:下一步干嘛?
├─ 有 toolCalls → 执行工具,再问下一轮
└─ 只有 content → 任务完成,返回最终文字
所以它不是「执行工具」的,而是 让模型做决策 的那一层;真正读写文件的是后面的 ToolExecutor。
四、工具设计:读、写、改,三种小手 ✋
文件类 Agent 通常至少需要三个工具:
| 工具 | 人设 | 适用场景 |
|---|---|---|
read_file | 悄悄看书的内向同学 | 先看看再改 |
write_file | 新建派小能手 | 创建或整文件覆盖 |
edit_file | 精细手术刀 | 按片段精确替换 |
edit_file 的「精确替换」特别值得学:比整文件 rewrite 更稳,也更省 token。
实现上就是找 old_string,校验唯一性,再替换:
if (occurrences === 0) throw new Error('找不到这段文字');
if (!replaceAll && occurrences > 1) throw new Error('不唯一,请加更多上下文');
**安全层千万别省! **
在 Extension 里做路径校验:相对路径、禁止 ..、扩展名白名单、限制在工作区根目录内。
记住口诀:模型不可信,执行层要可信 🛡️
五、流式 UI:Agent 的输出不只是「打字机」⌨️
纯 Chat 的流式很简单
token 来了 → 推到 UI → 逐字显示
Agent 更热闹一点:除了最终文字,还有工具状态、步骤计划,这些是结构化的,不是纯 Markdown。
有个超级实用的模式叫:
在文本通道上承载结构化流(NDJSON)
Extension 推给 Webview 的 chunk 可能长这样:
{"type":"steps","id":"s1","steps":[{"title":"编辑 index.ts","status":"pending"}]}
{"type":"tool","id":"t1","name":"edit_file","status":"running"}
{"type":"tool","id":"t1","name":"edit_file","status":"done","result":"..."}
Webview 端逐行 JSON.parse:
- 同
id的tool→ 合并更新(running → done) steps→ 渲染步骤条- 解析不了的行 → 当
Markdown文本
**为什么不直接传对象? **
因为 Agent 输出是渐进式的:
工具开始 ➡️ 工具结束 ➡️ 步骤变化 ➡️ 都是分多次推
把增量 append 到一个 text 字段,历史存储和流式协议就能统一成「一个字符串」——实现更轻松,扩展也更友好 🌸
六、步骤条:让 Agent 不再黑盒 📋
用户看 Agent 改代码,最怕两件事:
- 不知道它在干嘛
- 不知道还要等多久
步骤条就是来拯救信任感的~
两种生成方式可以一起用:
**方式 A:模型主动规划 **
调工具前先输出一行:
{"type":"steps","steps":[{"title":"编辑 index.ts"},{"title":"创建 config.js"}]}
**方式 B:从工具调用反推 **
模型没规划也没关系,根据 write_file / edit_file 自动生成标题。
状态流转:
pending → running → done / error
read_file 可以当「幕后工作者」——参与决策,但不占步骤位,避免步骤条被「读取 xxx」刷屏 🙈
这是 Agent UX 里很值得抄的一点:把
planning和execution可视化,用户会安心很多。
七、文件附件:给 AI 塞一张小纸条 📎
用户把文件附到消息上,常见两种做法:
| 做法 | 优点 | 缺点 |
|---|---|---|
| 只传路径,让 Agent 自己读 | 省 token | 多一轮工具调用 |
| 发送时注入文件内容 | 模型立刻有全文 | 大文件费 token |
第二种可以拼成这样的上下文块:
<file_context>
<file path="src/index.ts">
// 完整内容
</file>
</file_context>
再在 System Prompt 里写清楚:用附件路径,别在别的地方重复创建。
这是典型的 Prompt + 工具 + UI 三角配合:
- UI 提供附加能力
- Extension 注入上下文
- Prompt 约束模型行为
缺一角,体验就容易歪掉~
八、暂停/恢复:双端缓冲的小温柔 ⏸️
流式暂停看起来简单,实现却容易踩坑:
Extension还在推,Webview已暂停 → 可能丢chunkWebview缓存了,Extension以为结束了 →UI卡住
稳妥做法:两端都备一个小缓冲
暂停时:
Extension → 写入 _chunkBuffer
Webview → 写入 chunkCache
恢复时:
两边一起 flush,再处理 StreamEnd
这是分布式系统里「背压」的迷你版,做 Chat 产品迟早会遇到,趁早学会不吃亏 💡
九、构建:两个产物,一条流水线 🔧
VS Code 扩展 + React Webview,通常是双构建:
webpack → dist/extension.js (Extension Host)
vite → dist/webview/ (聊天 UI)
加载 Webview 时要用 webview.asWebviewUri() 转换资源路径,并注入 CSP。
本地开发记得先 build Webview,不然侧边栏会空白——这是新人高频踩坑点,抱抱~ 🥹
十、你可以直接带走的小 Checklist ✅
想自己做一只?按这个顺序推进比较稳:
- 搭
Webview聊天 UI +postMessage双向通信 - 接
LLM纯文本对话(先不做Agent) - 加
StreamStart/Chunk/End,做流式展示 - 定义 1~2 个工具,实现
Agent循环 - 加路径校验和扩展名白名单
- 用
NDJSON推送工具状态,Webview解析渲染 - (可选)步骤条、文件附件、暂停恢复
- (可选)真
token流式,替换最终回复的一次性输出
一步一步来,小助手会越养越乖~
希望你带走的不该只是「某个文件夹叫什么」,而是这些可以换到 JetBrains 插件、Electron 桌面应用、甚至 Web IDE 的通用思路。
欢迎继续折腾,养出你自己那只好看又靠谱的 AI 小助手 🐣✨