Claude Code 和 ZCode 接连曝出安全问题,我翻了一遍开源 opencode 的源码

26 分钟阅读

最近 Claude Code 和 ZCode 相继被曝出安全问题。这类工具要读你的代码、跑你的命令, 一旦出事代价不小,而闭源产品里到底做了什么,你基本看不到。开源的 agent 助手让人放心一些, 至少能直接翻源码。今天就来研究一下 opencode 是怎么帮你改代码的。

让 AI 直接动你的代码库,多数人第一反应是不放心。它会不会改错文件,会不会顺手删掉点什么,跑到一半断了怎么办。

最近这段时间,这类担心变得更具体了。Claude Code 和 ZCode 接连被曝出安全问题。它们要读你的代码、跑你的命令,权限给得很大,出了事代价不小,而闭源产品里到底发生了什么,你基本看不到。相比之下,开源的 agent 助手让人放心一些,至少能把源码翻出来,看它到底怎么动手,又给自己设了哪些规矩。

今天就来研究一下 opencode 是怎么帮你改代码的。我把它的源码翻了一遍,想看清它在这些地方具体做了哪些事。

它的运转其实就一个循环。想清楚下一步做什么,动手,看结果,再想下一步。剩下的一大堆设计,都在保证这个循环跑得稳。

先认识几个词

不用背,混个脸熟就行。

词大白话解释一个比喻
大模型 / LLM一个读过海量文字、能续写和下判断的“超级文字接龙机器”一个很聪明但只动嘴不动手的顾问
AI 助手 / Agent大模型 + 一双“手”(能读写文件、跑命令)+ 一套规矩顾问身边配了个能替你干活的助理
提示词 / Prompt你发给 AI 的话你给助理的指令
上下文 / ContextAI 在当前这一刻“眼前能看到的所有资料”助理桌上摊开的文件
TokenAI 处理文字的最小单位,可以粗略理解成“字/词的碎片”按字数收费的稿纸格
工具 / ToolAI 可以调用的具体动作,比如“读文件”“改文件”“运行命令”助理手里的螺丝刀、锤子、扫描仪
会话 / 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 日”。第二天你又来找它。

  1. 它翻开变化记录,日期这项上次是 3 月 1 日。
  2. 现在一看是 3 月 2 日,变了。
  3. 它不重印手册,往对话里追加一条补充说明,“今天是 3 月 2 日”。
  4. AI 看到手册加补充说明,就知道以新日期为准。
  5. 它顺手把变化记录里的日期也更新成 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 觉得需要时,才通过一个受权限控制的“取手册”动作把某本手册打开看。

桌面太满怎么办

资料多到快放不下时,它会做一次会议纪要压缩。

  1. 把较早的对话总结成一段摘要。
  2. 最近的一小段保留原文。
  3. 摘要替换掉旧记录,腾出空间。
  4. 重印一份全新的基准便签,因为前面的对话已经折叠,得重新开局。

桌子满了就把前面的讨论浓缩成会议纪要,只留最近几页原文,然后接着聊。

机制数字位置
什么时候触发压缩估算字数超预算时packages/core/src/session/compaction.ts:232-243
最近保留多少原文约 8000 tokencompaction.ts:137-158
摘要最多多长约 4096 tokencompaction.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 行 或 50KBpackages/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,等到一个安全时机,才真正放进模型能看到的对话里。

先登记不直接上桌,是因为让模型看到这件事有前置条件,比如基准便签得先准备好。条件没满足,就把话留在收件箱里等,不会硬塞进去。这样消息不会丢,也不会让模型看到半成品。

压缩怎么改写记忆

对话太长、模型快放不下时,它会做一次会议纪要压缩。

  1. 把较早的对话总结成一段摘要。
  2. 最近的一小段保留原文。
  3. 摘要替换掉旧记录,腾出空间。
  4. 重印一份全新的基准便签,让新一轮从干净状态起步。

桌子满了就把前面的讨论浓缩成会议纪要,只留最近几页原文,然后接着聊。

要留意的是,压缩会改写记忆的呈现方式。原来的逐字对话没了,变成一段摘要。但流水账里的原始记录还在,只是不再逐条发给模型了。

摘要丢了关键记忆怎么找回

压缩不会删除任何原始记录,它只改变发给模型的内容,不改变存下来的内容。关键记忆大多还能找回来。

做压缩的代码里没有任何删除操作,它只是把旧对话折叠进摘要。原始对话仍然躺在两个地方,会话消息表和事件日志。摘要本身也是持久记录,压缩结束时会保存实际生成的完整摘要文本,外加被保留的近期原文。

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 编程工具都长得差不多,因为它们都要面对同样几条硬约束。

  1. 大模型只会说话,不会动手。任何 agent 都必须自己准备一套工具,把模型的话翻译成真实动作。
  2. 模型的桌面有限,也就是上下文窗口有限。都必须做上下文筛选、压缩、按需加载。
  3. 模型输出不可全信。都必须有权限、审批或沙箱层兜底。

所以你会发现,几乎所有 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,上手会快很多。它们都在解同一道题,让一个只会说话的模型,安全、稳定、可回滚地在真实代码库上干活。

评论