Claude Code 和 ZCode 接连曝出安全问题,我翻了一遍开源 opencode 的源码
最近 Claude Code 和 ZCode 相继被曝出安全问题。这类工具要读你的代码、跑你的命令, 一旦出事代价不小,而闭源产品里到底做了什么,你基本看不到。开源的 agent 助手让人放心一些, 至少能直接翻源码。今天就来研究一下 opencode 是怎么帮你改代码的。
让 AI 直接动你的代码库,多数人第一反应是不放心。它会不会改错文件,会不会顺手删掉点什么,跑到一半断了怎么办。
最近这段时间,这类担心变得更具体了。Claude Code 和 ZCode 接连被曝出安全问题。它们要读你的代码、跑你的命令,权限给得很大,出了事代价不小,而闭源产品里到底发生了什么,你基本看不到。相比之下,开源的 agent 助手让人放心一些,至少能把源码翻出来,看它到底怎么动手,又给自己设了哪些规矩。
今天就来研究一下 opencode 是怎么帮你改代码的。我把它的源码翻了一遍,想看清它在这些地方具体做了哪些事。
它的运转其实就一个循环。想清楚下一步做什么,动手,看结果,再想下一步。剩下的一大堆设计,都在保证这个循环跑得稳。
先认识几个词
不用背,混个脸熟就行。
| 词 | 大白话解释 | 一个比喻 |
|---|---|---|
| 大模型 / LLM | 一个读过海量文字、能续写和下判断的“超级文字接龙机器” | 一个很聪明但只动嘴不动手的顾问 |
| AI 助手 / Agent | 大模型 + 一双“手”(能读写文件、跑命令)+ 一套规矩 | 顾问身边配了个能替你干活的助理 |
| 提示词 / Prompt | 你发给 AI 的话 | 你给助理的指令 |
| 上下文 / Context | AI 在当前这一刻“眼前能看到的所有资料” | 助理桌上摊开的文件 |
| Token | AI 处理文字的最小单位,可以粗略理解成“字/词的碎片” | 按字数收费的稿纸格 |
| 工具 / Tool | AI 可以调用的具体动作,比如“读文件”“改文件”“运行命令” | 助理手里的螺丝刀、锤子、扫描仪 |
| 会话 / Session | 你和 AI 的一次连续对话(一个项目里可以有很多次) | 和同一个助理的一次长谈,有会议记录 |
有个前提得先讲清。大模型自己碰不到你的电脑,它只会“说话”。去读文件、改代码、执行命令的是外面这层程序,也就是 opencode。所以“AI 会不会乱改我的代码”这个问题,答案取决于外面这层程序设了多少规矩。
一、OpenCode 是什么
它是一个跑在你电脑上的 AI 编程助手,有终端界面、网页界面和桌面应用。你可以对它说
- “帮我看看这个报错是怎么回事”
- “把登录功能加上记住密码”
- “这个函数太乱了,拆一下”
- “跑一下测试,把失败的修好”
先看哪些文件、再改哪一行、改完跑什么命令验证,这些它自己会安排。你主要负责提需求和最后审阅。
它和网页版聊天机器人最大的区别在这一点,它能直接动手改你电脑上的项目。下面要讲的,就是它敢动手背后的六层保命措施。
二、整体长什么样
先把它想成一个分工明确的公司。
graph TD
You["你<br/>提需求 / 看结果 / 点同意"] --> UI["前台 / 界面<br/>终端、网页、桌面应用"]
UI --> Server["前台接待处<br/>接收请求、排队、广播进展"]
Server --> Brain["大脑 / 核心引擎<br/>决定下一步做什么"]
Brain --> LLM["外部顾问(大模型)<br/>只出主意,不动手"]
Brain --> Tools["工具箱<br/>读文件、改文件、搜代码、跑命令、上网"]
Tools --> Disk["你的项目文件"]
Brain --> Memory["档案室<br/>把整段对话和每次改动记下来"]
拿一句“帮我加个按钮”走一遍。
sequenceDiagram
participant You as 你
participant App as 界面
participant Core as 核心引擎
participant DB as 档案室(数据库)
participant LLM as 大模型
participant Tools as 工具箱
You->>App: “帮我加个按钮”
App->>Core: 把这句话送进去
Core->>DB: 先把你的话“登记入册”
DB-->>Core: 登记成功
Core->>Core: 整理桌上资料(项目说明、历史对话、可用工具)
loop 反复思考+动手
Core->>LLM: 现在情况是这样,下一步做什么?
LLM-->>Core: 我想先看看某个文件
Core->>Tools: 去读那个文件
Tools-->>Core: 文件内容
Core->>LLM: 文件内容给你
LLM-->>Core: 我想改这一行
Core->>Tools: 去改文件(可能先问你同不同意)
Tools-->>Core: 改好了
end
Core->>DB: 把这段经过记下来
Core-->>App: 把进展实时推给你看
App-->>You: 你看到文字一个个蹦出来
思考、动手、看结果、再思考。这套循环看着简单,难的是它每转一圈都可能出岔子。后面几节,按出岔子的位置分开讲。
三、它怎么读懂你的项目
大模型每次开口之前,你得把它需要知道的资料摆到它面前。项目结构、你的要求、之前聊了啥、有哪些工具。这些合起来叫上下文。
麻烦的地方有三处。AI 的桌面是有限的,能看的字数有上限。资料还一直在变,你改了文件、时间过了、任务换了。摆太多又慢又贵又不准,摆太少它会瞎猜。上下文管理因此成了这类产品最核心的功夫。
opencode 的做法像一个会整理办公桌的助理,不把所有文件一股脑堆给顾问,而是分门别类。一份长期不动的资料夹,放项目说明、今天日期、可用技能清单。一摞随时更新的会议记录,记你说了什么、AI 做了什么。需要时临时调取的专项文件,比如某个技能的详细说明。
它把上下文分成三层。
| 层 | 里面放什么 | 大白话 |
|---|---|---|
| 系统上下文 | 工作目录、操作系统、项目里的 AGENTS.md 说明文件、可用技能清单、日期 | “助理每次开工前会去查看的那些背景资料” |
| 上下文纪元 | 一份“基准便签” + 一份“变化记录” | “把背景资料定稿成一份便签,并持续记录后来发生的变化” |
| 会话历史 | 你说过的话、AI 回过的话、工具做过的事 | “会议记录本” |
这三层里最容易听糊涂的是基准便签,单独讲一下。
基准便签是什么
它每次干活前,都会自动收集一批背景资料。你在哪个文件夹、什么操作系统、项目里的 AGENTS.md 写了什么、有哪些技能可用、今天是几号。
麻烦在于这些资料有的会变,日期每天都变,AGENTS.md 你随时可能改,有的不会。每聊一轮都把整套资料重新发一遍,会有两个问题。一是又慢又贵,资料可能几千字。二是 AI 会晕,开场白每次都不一样,它很难保持前后一致。
所以它把资料拆成三样,各管各的。
| 名字 | 是什么 | AI 能看到吗 | 会不会变 |
|---|---|---|---|
| 基准便签(baseline) | 第一次收集到的完整背景资料,定稿后冻结不再改 | 能,作为“开场简报”发给 AI | 不变 |
| 变化记录(snapshot) | 一本小账,记着“每项资料上次交给 AI 时是什么值” | 不能,纯粹给自己比对用 | 每轮更新 |
| 补充说明 | 某条资料变了,就单独追加一句话通知 AI | 能,夹在对话里 | 只增不改 |
换成生活里的说法。基准便签是入职第一天发的那本员工手册,发完就定版。补充说明是后来一张张补充通知,贴在手册旁边。变化记录是人事手里的一张对照表,记着哪一条已经通知过更新。
基准便签什么时候生成
这点最容易误会。创建会话不会生成,你刚发出消息也不会生成。
它挑的时机是第一次准备把内容发给大模型之前。AI 已经决定要干活、马上要开口问大模型的那一刻,它才临时去把背景资料收齐、定稿成便签。收集资料本身有成本,要查目录、读文件、拉技能清单。你建了会话却一直没发消息,就没必要提前干活。这种用到才生成的做法叫惰性生成。
每一轮准备调用大模型前,它都会先检查这个会话有没有基准便签。没有就现在收集,生成一份存进档案室。已经有就跳过,直接复用。
这里还有个安全上的小细节。收集资料时,某项重要的资料暂时读不到,比如 AGENTS.md 正好被占用,它不会硬生成一份残缺的便签,而是暂停这一轮,让你的消息继续留在待处理状态,稍后可以重试。宁可晚一点,也不给 AI 一份不完整的开场简报。
除了第一次,压缩之后也会重印一份新的基准便签。设计上,会话被“搬家”到另一个位置后也会重印,但这个能力目前还没启用。
举个日期的例子
第一次开工时,基准便签里写着“今天是 3 月 1 日”。第二天你又来找它。
- 它翻开变化记录,日期这项上次是 3 月 1 日。
- 现在一看是 3 月 2 日,变了。
- 它不重印手册,往对话里追加一条补充说明,“今天是 3 月 2 日”。
- AI 看到手册加补充说明,就知道以新日期为准。
- 它顺手把变化记录里的日期也更新成 3 月 2 日,下次不会再重复通知。
AGENTS.md 被改了也是同样的道理,补充说明会写成一段完整的新版说明,替换掉之前加载的所有项目说明。
为什么不每轮重发一整份
一是省钱省时间,只通知变化,不用每轮搬运几千字。二是大模型服务商对开头固定的长内容有缓存,重复使用能更快更便宜,开场简报越稳定越划算。三是有个稳定底稿,AI 更容易保持一致。四是注意力能用在刀刃上,让 AI 关注刚刚变了什么,而不是重读一遍已知内容。
把生成时机串起来看。
flowchart TD
A["创建会话"] --> B{"你发消息了吗?"}
B -->|"没有,一直没聊"| C["不生成<br/>省下收集资料的开销"]
B -->|"发了,AI 准备开工"| D["第一次调用大模型之前"]
D --> E{"这个会话<br/>已有基准便签?"}
E -->|没有| F["现在收集资料<br/>生成基准便签"]
E -->|有| G["直接复用,不重复生成"]
F --> H["之后有变化只追加补充说明"]
G --> H
H --> I{"发生整合压缩?"}
I -->|是| J["重印一份全新的基准便签"]
I -->|否| K["继续用旧便签 + 补充说明"]
sequenceDiagram
participant Core as 核心引擎
participant Registry as 资料收集员
participant Epoch as 基准便签 + 变化记录
participant History as 会议记录本
participant LLM as 大模型
Core->>Registry: 把背景资料都看一遍
Registry-->>Core: 目录/系统/说明文件/技能清单/日期
Core->>Epoch: 有基准便签吗?
alt 第一次
Core->>Epoch: 生成基准便签,并记下变化记录
else 已有
Core->>Epoch: 用变化记录逐项比对
opt 发现某几项变了
Core->>Epoch: 追加一条“补充说明”,并更新变化记录
end
end
Core->>History: 取这段对话的记录
Core->>LLM: 基准便签 + 补充说明 + 会议记录 + 工具清单
项目说明文件 AGENTS.md
你在项目根目录放一个叫 AGENTS.md 的文件,相当于给 AI 看的员工手册,opencode 会自动读取交给 AI。它会从当前文件夹一层层往上找,也会额外读你电脑全局配置里的那份。某个文件暂时读不到时,它保留旧内容继续用,不会以为文件被删了。这个设计叫陈旧但保留,用起来挺贴心。
它跨对话记住你项目规矩,主要就靠这个。没有什么神秘记忆库,每次开工重新读你写的说明文件。
技能怎么给
技能是一本本专业手册。它不会把所有手册正文都塞给 AI,太占地方,只给一份目录,包含技能名和一句话介绍。AI 觉得需要时,才通过一个受权限控制的“取手册”动作把某本手册打开看。
桌面太满怎么办
资料多到快放不下时,它会做一次会议纪要压缩。
- 把较早的对话总结成一段摘要。
- 最近的一小段保留原文。
- 摘要替换掉旧记录,腾出空间。
- 重印一份全新的基准便签,因为前面的对话已经折叠,得重新开局。
桌子满了就把前面的讨论浓缩成会议纪要,只留最近几页原文,然后接着聊。
| 机制 | 数字 | 位置 |
|---|---|---|
| 什么时候触发压缩 | 估算字数超预算时 | packages/core/src/session/compaction.ts:232-243 |
| 最近保留多少原文 | 约 8000 token | compaction.ts:137-158 |
| 摘要最多多长 | 约 4096 token | compaction.ts:15 |
| 字数怎么估 | 按“字符数 ÷ 4”粗估 | packages/core/src/util/token.ts:3-5 |
它的字数估算很粗糙,不是精确计算,这是目前的一个小短板。
便签存在哪,每次又发了什么给模型
先回答存储。基准便签存在本地数据库的 session_context_epoch 表里,三个字段。baseline 是那份定稿的开场简报文本,snapshot 是模型看不到的变化记录,baseline_seq 记着它从哪个事件序号开始生效。数据库文件在系统的数据目录下,你这台机器上一般是 ~/.local/share/opencode/opencode.db。这张表每条会话最多一行,进程重启后直接复用,不用重新生成。
再看一次请求里到底装了什么。每当它要问大模型,请求由三块拼成。
第一块是 system,分两段。一段是当前工作模式的系统提示,规定角色和基本规则。另一段就是基准便签。便签里有工作目录、操作系统、项目里的 AGENTS.md 指令、可用技能清单、引用说明、当天日期,以及插件注册进来的来源。
第二块是 messages,也就是这份会话历史投影出来的消息。里面有你发的消息和附件、AI 回的文本和推理、它调用过的工具和工具结果、对话中途的补充说明、Shell 命令和输出。压缩过的会话,历史里会多出一条摘要,被包进 <conversation-checkpoint> 标签,当成一条用户消息发出去。工作模式和模型的切换记录不发给模型,它们只留档。
第三块是 tools,当前工作模式能用的工具定义。到了步数上限的最后一步,工具会被收走,改成追加一句提醒它收尾的话。
随请求一起走的还有模型参数、缓存键和几个会话标识头。凭据在这一层注入,不会变成文字出现在上下文里。
模型看不到的东西也值得说清。原始事件日志不发,变化记录的 snapshot 不发,被压缩折叠掉的旧逐字对话不发,工具大输出的完整文件不发,只发开头结尾的预览和一句文件路径。快照里的 git 内容是给撤销用的,跟模型无关。
所以模型每次看到的,都是现场重新拼出来的一份。基准便签固定不动,补充说明往后面加,历史按压缩边界裁一遍。它手上没有一份持续累积的存档可以翻。
四、它怎么保证不闯祸
核心态度只有一句,AI 说的话不能全信。所以它默认不信任 AI,只信任三样东西。你项目所在的文件夹边界,你配置的规则,你在弹窗上的点按。
拿装修打比方。AI 是个能力很强但你不完全放心的装修工。你可以让他进屋干活,但只能在指定的房子里动,不能翻到邻居家。有些危险动作,比如砸承重墙,必须每次问你。你同意过一次的,可以记下来以后自动放行。
文件夹边界
它把“你的项目文件夹”当作一堵墙。AI 想读写文件时,系统会先把路径拼成完整路径,检查是否在项目内,再检查这个路径经过快捷方式,也就是符号链接,之后是否仍然在项目内。两道检查都过不了,才挡得住 AI 用 ../ 或快捷方式偷看项目外的文件。
目前有个已知的小漏洞。搜索类工具的搜索起点没做同样严格的检查,AI 理论上能越界搜索。这是它要修的地方。
权限的三种结果
每次 AI 想做一个敏感动作,系统会得出三种结果之一。
flowchart TD
A["AI 想做一个动作<br/>比如改文件、跑命令"] --> B{"你配置过规则吗?"}
B -->|"配了禁止"| DENY["直接拒绝"]
B -->|"配了允许"| ALLOW["直接放行"]
B -->|"没配 / 配了要问"| ASK["弹窗问你"]
ASK --> C{"你怎么选?"}
C -->|"只这次同意"| ONCE["这一次放行"]
C -->|"以后都同意"| SAVE["记进档案,之后自动放行"]
C -->|"拒绝"| DENY
DENY --> D["AI 收到“被拒绝”,换别的方式或停下来"]
ONCE --> E["继续干活"]
SAVE --> E
ALLOW --> E
有三个细节值得留意。
已保存的允许,永远盖不过你明确配置的禁止。安全优先。
系统认不出当前是哪个工作模式时,默认全拒。宁可不动,也不乱动。
你中途换了工作模式,比如从随便问切到只读,正在进行的那个动作仍然按旧规矩走。这样不会出现切换瞬间被偷跑一个危险操作。
命令和终端
AI 跑 Shell 命令时,比如安装依赖、跑测试,权限是以整条命令为单位来问你的。这里要提醒一句,命令一旦放行,它就拥有你电脑用户的完整权限,opencode 没有沙箱。所以同意跑命令这件事要慎重。
终端还有个专门的一次性门票机制,防止网页上的坏人偷偷接管你的终端。
网络和密钥
联网抓网页时,只允许 http/https,有大小和超时限制,还会清洗掉网页里的脚本。不足的地方是,它没有专门拦截内网地址,理论上存在被利用去探测内网的风险。
你的 API 密钥等凭据存在本地数据库里,对外只暴露名字,不会出现在 AI 的对话记录里。发给大模型服务商时会从请求里剥离。
五、它怎么动手改代码
工具就是动作清单
AI 不能直接改文件,它只能说,我想调用 edit 工具,参数是这些。opencode 收到后会做四件事。先检查参数格式对不对,防止 AI 说胡话。然后检查权限。接着真正执行。最后把结果整理成 AI 能看懂的形式回传。
它内置的工具大致有这些。读文件、写文件、改文件、打补丁、按文件名搜、按内容搜、跑命令、开终端、抓网页、搜网络、加载技能、更新待办、向你提问。
一次工具调用的完整过程
sequenceDiagram
participant LLM as 大模型
participant Core as 核心引擎
participant Registry as 工具管理员
participant Tool as 具体工具
participant Store as 大文件仓库
LLM-->>Core: 我要调用 edit 工具
Core->>Registry: 查一下这个工具在不在、参数对不对
Registry->>Tool: 执行
Tool-->>Registry: 结果(可能是很长的一大段)
Registry->>Store: 结果太长了,裁剪一下
Store-->>Registry: 只留开头结尾的预览 + 完整文件另存
Registry-->>Core: 整理好的结果
Core->>LLM: 结果给你,请继续
结果太长怎么办
AI 的桌面有限。某个命令输出一大堆内容时,比如测试日志几万行,opencode 会把完整内容写到一个临时文件里,只把开头和结尾的片段,外加一句“完整内容在某某文件里”,交给 AI。这些临时文件保留 7 天,之后自动清理。
| 规则 | 数值 | 位置 |
|---|---|---|
| 超过多少行/字节就另存 | 2000 行 或 50KB | packages/core/src/tool-output-store.ts:13-14 |
| 临时文件保留多久 | 7 天 | tool-output-store.ts:15 |
| 怎么清理 | 每小时扫一次 | tool-output-store.ts:176-205 |
工具会不会过期
你中途把工作模式从开发切到只读,AI 手里可能还攥着旧的工具清单。它拿旧清单去调用新工具,系统会发现编号对不上,直接返回“这个工具已过期”,不会错误地执行。
六、它为什么不容易崩
目标很朴素。单个工具失败、网络抽风、你突然点停、电脑断电,都不应该让整个程序挂掉或把数据弄乱。
一个好的餐厅不会因为一道菜做糊了就关门,它把这盘倒掉,告诉客人,继续做下一道。
错误当成正常结果
工具执行失败,比如文件不存在,会变成一个 AI 能读到的错误信息,让 AI 自己决定下一步,不会让程序崩溃。事件监听器,也就是负责把进展推给界面的小工,出错时也只记一笔日志,不影响主流程。
网络请求会重试
访问大模型服务时,遇到请求太频繁(429)或服务器忙(5xx)这类临时问题,它会自动等一会儿再试,最多重试 2 次。等待时间指数增长,还带随机抖动,避免所有请求同时挤上去。
flowchart TD
A["向大模型发请求"] --> B{"成功了吗?"}
B -->|是| OK["继续"]
B -->|"429 / 500 / 503 / 504"| RETRY{"还有重试次数吗?"}
RETRY -->|有| WAIT["等一会(越来越久 + 随机抖动)"] --> A
RETRY -->|没有| FAIL["告诉上游:失败了"]
B -->|"401 / 403 没权限"| FAIL
B -->|"400 且是内容太长"| COMPACT["先压缩对话,再试一次"] --> A
内容太长是特殊的
大模型说“你发的东西太长了”时,opencode 不会傻等报错,它会触发上一节的会议纪要压缩,腾出空间再发一次。这个补救只允许做一次,免得陷入死循环。
你点停止时会发生什么
这是最考验功力的地方。好的中断要做到,已经做完的事记清楚,没做完的事标明白,不能留下半拉子状态。
它的做法有四条。“停止”这个请求本身不能被中途取消,否则会卡在中间。正在跑的工具会被中断,并被明确标记为执行被中断。如果 AI 已经说了一半话,那半句也会被标记完成或失败。已经成功、但还没来得及记录的工具结果,会被保护着先记完,这段过程不可打断。
紧急叫停一条流水线时,它让当前这个零件走完并贴好标签,再把没做的标成取消,最后才停。整个过程不直接拔电源。
断电或重启会怎样
所有重要事情都记在本地数据库里,重启后能恢复大部分状态。但有一条明确规则。
断电时没做完的工具调用,重启后一律标成失败,让 AI 看到失败信息后自己决定要不要重试。它不会偷偷重新执行。
这个选择很保守,但很安全。AI 可能已经改了一半文件,悄悄重跑可能造成重复改动。
有几样东西是不持久、重启即丢的。进行中的大模型请求、后台任务,以及工具副作用做到一半的中间状态。这是它目前公开承认的局限。
跑命令不留僵尸进程
AI 跑的命令如果卡死或没关干净,可能变成占用资源的僵尸。opencode 会先礼貌地请它退出,也就是 SIGTERM。它不退就强制结束,也就是 SIGKILL。并且连同它启动的一整个进程组一起清理,避免留下孤儿进程。
七、你平时怎么用它
你发的消息其实分两步
看起来你按下回车消息就发出去了,内部是两步。先登记(admitted),把你的话稳稳当当写进数据库,这条要求不会丢。再上桌(promoted),等到一个安全的时机,才真正放进 AI 能看到的历史里。
好处是,程序在两步之间崩溃,你的话也不会丢,重启后还能继续。有些产品界面上先显示“排队中”,就是这个道理。
消息为什么能一个字一个字蹦出来
AI 的回答不是一口气生成的,它一个词一个词往外吐。opencode 用一个叫 SSE 的单向广播技术,把每个词实时推给你的界面。
sequenceDiagram
participant You as 你的界面
participant Stream as 实时广播
participant Core as 核心引擎
You->>Stream: 订阅“有进展就通知我”
Stream-->>You: 连接成功
loop AI 回答中
Core->>Stream: 刚吐出“登”
Stream-->>You: 显示“登”
Core->>Stream: 刚吐出“录”
Stream-->>You: 显示“登录”
end
Note over Stream,You: 网络断开
You->>Core: 重新拉取完整历史
You->>Stream: 重新订阅
有个设计我觉得挺聪明。界面断开重连后,它不要求服务器把断线期间的消息补发一遍,因为实时流不保证不漏。它直接找服务器重新要一份完整历史。实现简单,也不怕漏。
界面还会把同一个字的多条推送合并起来再刷新,每 16 毫秒一次,避免频繁刷新导致卡顿。
需要你做决定时
权限弹窗会在 AI 做敏感操作时弹出,显示它想干什么,比如改哪个文件的哪几行,给你三个选项,只这次允许、以后都允许、拒绝。拒绝时还可以写句理由,AI 会看到。
提问卡片是 AI 反过来问你问题,比如“你想要哪种风格”。界面弹出选项,支持单选、多选和自定义输入。
停止按钮
终端界面里连着按两次 Esc 才会真正停止,第一次会提示再按一次即可中断,防止你手滑误触。停止请求可以重复发送,没在跑的时候发也没关系,不会报错。
中途换工作模式或模型
你可以随时切换 AI 的工作模式,比如更谨慎或更激进,也可以换模型。切换会在下一步生效,不会打断当前正在做的事。这样更稳。
后悔药,撤销
AI 一次可能改很多文件,万一改错,你需要能一键回到从前。opencode 的撤销分两块,快照和三步确认。
第一步,拍快照
每次 AI 动手的前后,它都会给整个项目拍一张快照。动手前拍一张记录起点,动手后拍一张记录结果。
几个关键点决定了它的能力边界。
- 存哪里。它不把文件复制一大堆,而是存进一个隐藏的 git 仓库,相同内容只存一份,很省空间。数据库里只记一个存档编号。
- 什么时候有。只对 git 项目生效,不是 git 项目就没有快照,也就不能用撤销。
- 拍什么。项目里被 git 管理的文件。未跟踪的文件如果超过 2MB,就不拍。
- 能不能关。可以在配置里关闭快照功能,写成
snapshots: false。
玩单机游戏时,每到关键节点会自动存一个档。存档不是把整个游戏世界复制一遍,它记下的是当前状态的指纹。
第二步,撤销为什么分三步
很多人第一次用会困惑,撤销不是点一下立刻删,它分成预览、反悔、确认三步。
| 动作 | 界面上的意思 | 干了什么 | 能反悔吗 |
|---|---|---|---|
| stage(暂存) | “我想撤销到这条消息之前” | 立刻把文件恢复成那个样子,并生成一份“改了什么”的差异清单 | 能 |
| clear(放弃) | “算了,不撤销了” | 把文件恢复成撤销前的样子,当作无事发生 | 不用 |
| commit(确认) | “确定,就这样” | 文件回滚保持不动,并删掉这条消息之后的所有对话记录 | 不能 |
stage 之后还能反悔,是因为它只改了文件、还没删对话。你可以在界面上先看看回滚后会变成什么样,觉得不对就 clear 掉。这比一键不可逆删除安全得多。
commit 要删对话,是为了让文件和 AI 的记忆保持一致。文件回到了过去,AI 记忆里却还留着那段被撤销的工作,下次聊天它就会精神分裂,明明记忆里改过,文件却没有。所以确认撤销时,opencode 会把那条消息之后的所有对话消息和你之后的输入一起删掉。
拿写文档打比方。你想删掉最近三段。stage 是先给你看看删完的样子,clear 是还是留着吧,commit 是真删,并且把这三段从历史记录里也抹掉。
撤销的三种状态
stateDiagram-v2
[*] --> 正常
正常 --> 已暂存: stage 预览回滚
已暂存 --> 正常: clear 放弃撤销
已暂存 --> 已确认: commit 确认
已确认 --> 正常: 文件回滚 + 删掉之后的对话
界面上怎么用
终端里 /undo 会先停住正在跑的任务,找到最后一条你的消息,执行撤销,并把你当时说的话还原到输入框,方便你改一改重新发。/redo 是反悔后再恢复。
网页和桌面上,按钮叫撤销和恢复,底层同样是 stage 和 clear 这套接口。
注意
它只回滚 AI 记录过的文件改动。你自己在别的地方手工改的文件,不在保护范围内。撤销的粒度是一条消息,不是某一行。快照对象库目前没有自动清理机制,会一直保留,因为它按内容存放,重复内容不占额外空间。
常见疑问,撤销是不是基于 git 的
一半对。准确说,它借用 git 当存储和回滚引擎,但用的是一套独立、隐藏的辅助仓库,不会改动你项目的 git 历史。
必须先是 git 项目,只有 git 仓库才有快照,不是 git 项目就没有撤销功能,也可以在配置里主动关闭。辅助仓库放在一个统一的缓存目录里,和你项目的 .git 不是同一个地方。它通过 git 的对象共享机制借用你项目的对象库,并复制一份索引,并不是把项目完整复制一遍。拍快照时,它用 git 的树对象记录当前工作区,只记录当前工作目录范围、遵守忽略规则、未跟踪的大文件跳过。回滚时,它选择性地把指定文件恢复成快照里的版本。
它不会给你的项目提交 commit,不动你的分支、暂存区和 git stash,也不会改写你的提交历史。所以它把 git 当成一套高效的存档系统来用,并没有调用 git 自带的撤销命令。这也解释了为什么撤销的粒度是快照和消息,而不是逐行。它记录的是整个工作区在某一刻的样子。
八、它怎么记住事情
这一节讲两件事。opencode 到底把什么记了下来,以及为什么这样设计。
它有四种记忆
别把它想成一个大脑,它更像四层分工不同的记忆。
| 记忆 | 存什么 | 活多久 | 比喻 |
|---|---|---|---|
| 工作记忆(当前上下文) | 这一轮实际发给模型的东西 | 一次请求,发完就“翻篇” | 桌上摊开的文件 |
| 对话记忆 | 这个会话里你们聊过的内容 | 会话内长期保存 | 会议记录本 |
| 持久账本 | 所有原始事件,按顺序 | 永久,直到你删会话或撤销 | 流水账 |
| 项目知识 | AGENTS.md、技能手册等 | 永久,存在你的文件系统里 | 员工手册 |
记三句话就够。工作记忆是临时的,账本是永久的,对话记忆是从账本算出来的。
一切以日志为准
opencode 的设计哲学是先把发生的事记下来,其他信息都可以从日志重新算出来。
会计做账是个好类比。原始凭证,也就是每一笔流水,最重要。各种报表,余额、汇总,都是从流水算出来的。报表算错了,拿流水重算就行。
这里的原始凭证是事件日志,记谁在什么时候做了什么。报表是当前对话长什么样、有哪些待办、文件改了什么。
好处有三条。报表坏了能从流水重算。重启后读一遍流水就能恢复局势。每一步都有编号(seq),谁先谁后清清楚楚。
代价是要小心保持流水和报表一致,所以它们写在同一个原子事务里,要么一起成功,要么一起失败,不会出现日志记了但报表没更新的错乱。
数据库里都有什么
| 档案 | 存什么 | 大白话 |
|---|---|---|
| 事件日志 | 按顺序记录所有事件(每会话一个编号序列) | 流水账 |
| 会话消息 | 当前对话的整理结果 | 会议记录 |
| 输入收件箱 | 你发了但还没轮到处理的话 | 待办盘 |
| 上下文纪元 | 那份“基准便签”和变化记录 | 桌上便签 |
| 快照 | 项目在某个时刻的样子(只记编号,内容在隐藏 git 仓库) | 游戏存档 |
| 权限档案 | 你同意过“以后都允许”的规则 | 通行证 |
| 凭据 | 你的 API 密钥 | 钥匙串 |
| 待办 | AI 列的 TODO 清单 | 便利贴 |
容易被误解的一点是,这里没有一张跨对话记忆表。opencode 不做记住你上次聊过什么的那种长期记忆。跨对话的记忆,就是每次重新读你写下的 AGENTS.md 等项目说明文件。想让它跨会话记住什么,你得写在文件里,别指望它自己记。
工具调用的状态也没有单独的表,它塞在会话消息的内容里。登录尝试、后台任务这类东西甚至只在内存里,不入库。
一条消息在记忆里的完整旅程
把上面几张表串起来,看一条消息的一生。
sequenceDiagram
participant You as 你
participant Core as 核心引擎
participant Inbox as 输入收件箱
participant Log as 流水账
participant Report as 会议记录
participant LLM as 大模型
You->>Core: 发出“帮我加个按钮”
Core->>Inbox: 登记(admitted),模型还看不到
Core->>Log: 记一笔“收到输入”
Note over Core: 等到安全时机
Core->>Inbox: 取出并上桌(promoted)
Core->>Log: 记一笔“输入已上桌”
Core->>Report: 投影成一条用户消息
Core->>LLM: 基准便签 + 补充说明 + 会议记录 + 工具
LLM-->>Core: 回答 + 工具调用
Core->>Log: 每个字、每次工具结果都记流水
Core->>Report: 同步更新会议记录
Note over Log,Report: 断电了
Core->>Log: 重启后从流水账重建
Core->>Report: 没做完的工具标为失败,待处理输入继续
其中两个词值得单独记住。登记,也就是 admitted,你的话先安全落库,此后程序崩了也不会丢。上桌,也就是 promoted,等到一个安全时机,才真正放进模型能看到的对话里。
先登记不直接上桌,是因为让模型看到这件事有前置条件,比如基准便签得先准备好。条件没满足,就把话留在收件箱里等,不会硬塞进去。这样消息不会丢,也不会让模型看到半成品。
压缩怎么改写记忆
对话太长、模型快放不下时,它会做一次会议纪要压缩。
- 把较早的对话总结成一段摘要。
- 最近的一小段保留原文。
- 摘要替换掉旧记录,腾出空间。
- 重印一份全新的基准便签,让新一轮从干净状态起步。
桌子满了就把前面的讨论浓缩成会议纪要,只留最近几页原文,然后接着聊。
要留意的是,压缩会改写记忆的呈现方式。原来的逐字对话没了,变成一段摘要。但流水账里的原始记录还在,只是不再逐条发给模型了。
摘要丢了关键记忆怎么找回
压缩不会删除任何原始记录,它只改变发给模型的内容,不改变存下来的内容。关键记忆大多还能找回来。
做压缩的代码里没有任何删除操作,它只是把旧对话折叠进摘要。原始对话仍然躺在两个地方,会话消息表和事件日志。摘要本身也是持久记录,压缩结束时会保存实际生成的完整摘要文本,外加被保留的近期原文。
flowchart TD
A["摘要好像丢了关键信息"] --> B{"想让它重新被模型看到?"}
B -->|要| C["写进 AGENTS.md<br/>或用新消息重新喂一遍"]
B -->|"只是想查证"| D["读会话历史接口(durable 事件日志)"]
B -->|"想翻原始数据"| E["直接查本地数据库的<br/>会话消息表 / 事件表"]
C --> G["模型下一轮又能看到"]
D --> F["拿到压缩前的原始事件<br/>文本、工具调用、报错等"]
E --> F
想看或导出原始内容,用会话的历史接口,它读的是 durable 事件日志,能拿到压缩前的文本、工具结果等原始事件。另一个“当前上下文”接口返回的是压缩后过滤过的视图,看不到被折叠的旧消息,想翻旧账要用历史接口。
想立刻让 AI 重新知道,把关键信息写进 AGENTS.md,这是最稳的,跨会话都生效。或者用一条新消息再喂一遍,新消息属于最近保留的范围,不会马上被二次折叠。想彻底翻数据,直接打开本地数据库的会话消息表和事件表找。
但它没有一键取消压缩。目前没有取消压缩、从摘要恢复的功能,手动压缩接口也还没开放,调用会返回功能不可用。所以不存在自动恢复,只能靠上面的人工办法。摘要由大模型生成,天然有损,产品里也没有自动检测这次摘要是否丢了关键信息的机制。
为了尽量少丢关键信息,压缩本身做了两件事。一是保留最近原文,最近的对话,默认约 8000 token,不参与摘要,原样保留。二是滚动摘要,第二次压缩时,它会把上一次的摘要、上一次保留的原文,以及这次要压缩的新内容一起交给模型重新汇总。摘要模板还明确要求保留具体的文件路径、函数名、命令、报错原文和链接,新旧信息冲突时以最新的为准,已完成的工作、约束和用户指令都要带过去。
这相当于每次重新整理都把旧的记忆一起带上,不会简单用新摘要盖掉旧的。
真要靠得住,长期记忆得放在文件里,尤其是 AGENTS.md。对话摘要只是省地方的手段,不是可靠的存储。
一次操作怎么安全落账
sequenceDiagram
participant Core as 核心引擎
participant DB as 数据库
participant Log as 流水账
participant Report as 报表
Core->>DB: 开始一个“事务”(要么全成,要么全不成)
Core->>Log: 记一笔流水(分配编号)
Core->>Report: 同步更新报表
opt 需要一起更新的便签
Core->>DB: 更新“基准便签的变化记录”
end
Core->>DB: 提交事务
DB-->>Core: 成功
事务这个词听着高大上,其实就是一揽子操作,要么全做完,要么全不做。这样就不会出现流水记了、报表却没更新的中间状态。
它不会记住什么
明确预期很重要。
不跨会话记你的聊天内容,除非你写进 AGENTS.md。后台任务不持久,程序一关就没了,进行中的工作会被中断。正在进行的网络请求不持久,断电就重来。工具做到一半的中间状态不保证安全,所以重启后没做完的工具会直接标为失败,不会偷偷续跑。登录尝试只在内存,不会长期保存。
临时文件与过期
工具大输出的临时文件保留 7 天,之后自动清理。除此之外,对话消息没有自动过期,会一直留着。会话可以归档,但目前没有自动删除机制。快照对象库也没有自动清理。后台任务不持久,程序一关就没了。
九、它现在还有哪些不足
没有完美的软件。下面是它公开承认或代码里明确写着待办的地方,用来建立合理预期。
安全方面。搜索工具按文件名或内容找时,路径没做和读写一样严格的边界检查,理论上能越界搜索。网页抓取没有专门拦截内网地址,存在被利用探测内网的风险。终端的工作目录没有严格限制在项目内。密钥在本地数据库里是明文存储的,没有接系统钥匙串。
稳定性方面。网络请求重试只覆盖请求发出阶段,一旦开始流式接收后出错就不会自动重试。没有同一个工具反复失败就自动熔断的机制。进程重启后,没做完的工具不会自动重跑,这是故意设计,但意味着可能中断。多个节点协同的分布式能力还没实现。
体验方面。终端界面走的还是旧引擎,网页和桌面走的是新引擎,两套并存,功能不完全一致。新引擎的部分接口还是实验性的,随时可能变。字数估算是粗略的字符数除以 4,不是精确计算。
十、术语对照和源码索引
| 文中说法 | 你可能听到的技术词 | 一句话解释 |
|---|---|---|
| 基准便签 | Baseline System Context / Context Epoch | 开工前定下、之后少改的上下文底稿 |
| 会议记录 | Session History | 你与 AI 的对话记录 |
| 补充说明 | Mid-Conversation System Message | 上下文变化时追加的一条系统消息 |
| 会议纪要压缩 | Compaction | 把旧对话总结成摘要以腾空间 |
| 登记 / 上桌 | Admitted / Promoted | 输入先持久化,再进入模型历史 |
| 快照 | Snapshot | 项目在某一刻的存档,用于撤销 |
| 流水账 | Event Log / Event Sourcing | 按顺序记录所有事件,作为唯一真相 |
| 报表 | Projection | 从流水账算出来的可查询数据 |
| 档案室 | SQLite Database | 本地数据库文件 |
| 实时广播 | SSE | 服务器单向推送消息给界面的技术 |
如果你也想去看代码,下面是我翻的时候记下的入口。
| 主题 | 从哪里看起 |
|---|---|
| 整体架构与双引擎 | CONTEXT.md、AGENTS.md、packages/core/src/session.ts |
| 上下文来源与拼装 | packages/core/src/system-context/index.ts |
| 项目说明文件读取 | packages/core/src/instruction-context.ts |
| 上下文纪元与压缩 | packages/core/src/session/context-epoch.ts、session/compaction.ts |
| 权限与安全边界 | packages/core/src/permission.ts、filesystem.ts、location-mutation.ts |
| 工具定义与调用 | packages/core/src/tool/tool.ts、tool/registry.ts |
| 大输出另存 | packages/core/src/tool-output-store.ts |
| 崩溃防护与重试 | packages/core/src/session/runner/llm.ts、packages/llm/src/route/executor.ts |
| 用户交互与事件流 | packages/protocol/src/groups/session.ts、packages/app/src/context/server-sdk.tsx |
| 数据库与迁移 | packages/core/src/database/database.ts、session/sql.ts |
十一、和其他 AI 编程工具比
这一节是延伸对比。opencode 不是特例,它和 Claude Code、Cursor、Cline、Aider、Codex CLI、OpenHands 属于同一类产品,只是取舍不同。下面的描述是各家的大致做法,细节会随版本变化,仅供参考。
原理同源,差别在工程取舍。几乎所有 AI 编程工具都长得差不多,因为它们都要面对同样几条硬约束。
- 大模型只会说话,不会动手。任何 agent 都必须自己准备一套工具,把模型的话翻译成真实动作。
- 模型的桌面有限,也就是上下文窗口有限。都必须做上下文筛选、压缩、按需加载。
- 模型输出不可全信。都必须有权限、审批或沙箱层兜底。
所以你会发现,几乎所有 agent 工具都能拆成同一个循环。
flowchart LR
A["用户需求"] --> B["组装上下文<br/>系统提示 + 历史 + 可用工具"]
B --> C["问大模型:下一步做什么"]
C --> D{"它要调用工具吗?"}
D -->|是| E["执行工具<br/>读 / 写 / 搜 / 跑命令"]
E --> F["把结果塞回上下文"]
F --> C
D -->|否| G["输出回答 / 结束"]
C -.->|出错| H["重试或降级"]
H --> C
上下文、工具、循环、权限、持久化、流式交互,这六样是这类产品的通用骨架,opencode 只是其中一个实现。
大家在各维度上的同源与分化大致是这样。
| 维度 | 通用做法 | 分化点(各家不同) |
|---|---|---|
| 上下文 | 系统提示 + 历史 + 压缩 | 有的靠工具搜索(grep / glob,OpenCode、Claude Code 等),有的靠代码向量索引 / RAG(Cursor、Windsurf 等预建索引) |
| 项目说明 | 约定一个说明文件 | AGENTS.md(OpenCode 等)/ CLAUDE.md / .cursor/rules / .github/copilot-instructions.md |
| 工具协议 | 函数调用 / JSON Schema | 正在统一到 MCP(可以理解为“工具的 USB 接口”),主流工具大多已支持 |
| 权限安全 | 询问 + 允许列表 | 差异最大,有的用 OS 级沙箱(Codex CLI 的 seatbelt / landlock、Claude Code 的沙箱、OpenHands 用 Docker),OpenCode 目前主要靠权限审批,没有系统级沙箱 |
| 记忆 | 短期靠对话历史 | 多数没有真正的跨会话记忆,靠说明文件;少数有“记忆”功能或外挂向量库 |
| 崩溃恢复 | 尽量持久化 | OpenCode 的事件溯源 + durable 收件箱比较激进;不少工具是内存循环 + 保存一份对话 JSON |
| 撤销 | 检查点 / 快照 | Cursor 检查点、Aider 靠 git 提交、OpenCode 用 git 快照 + revert |
| 多智能体 | 主 agent 派子任务 | Claude Code 子代理、OpenCode task 工具、各家的 subagent |
真正拉开差距的地方,我觉得是这几个取舍。
怎么找代码。全靠检索,也就是 RAG,还是让模型自己用搜索工具。前者快,后者更灵活,opencode 偏后者。
安全靠什么。靠操作系统沙箱更硬,靠应用层审批更灵活,但更依赖用户克制。这是产品之间最大的安全差异。
记忆怎么存。文件、向量数据库,还是专门的记忆系统。目前大多数还是文件加对话历史。
恢复做多深。要不要让断电后无缝续跑。做深了复杂,多数工具选择重启后把没做完的标记失败。
看懂 opencode 这一套,再看 Claude Code、Cursor、Cline、Aider,上手会快很多。它们都在解同一道题,让一个只会说话的模型,安全、稳定、可回滚地在真实代码库上干活。
评论