使用本地 LLM 测试发现严重提示缓存未命中:每次 SSE 轮次重读 AGENTS.md、agent→用户转换时修剪上下文导致 40k token 缓存失效、午夜日期变更引发完全缓存未命中。
如果你不知道什么是 OpenCode,可以想象一只靴子永远践踏在人脸上。这只靴子是由 TypeScript 制成的,而脸则代表自 1940 年代电子计算机发明以来我们所学到的所有关于安全性和系统软件的知识。创作者:https://opencode.ai/ 将其描述为一个 AI 编程代理。据我所知,它是最受欢迎的开源编程代理,目前在 GitHub 上有 161k 星标。
我尝试使用本地 LLM 运行 OpenCode。我的结论是,OpenCode 就像小丑车一样混乱,安全态度像是在说:“让我为你弯下腰,爸爸。” 每个使用它的人都应该停止使用。
这篇文章分为两部分:令人烦恼的事情和令人警觉的事情。第二部分更长。我写这篇文章参考了 OpenCode git 版本 baef5cd4 的源代码。
我不认为本文中的任何内容属于安全漏洞披露。OpenCode 本质上是一个用于管道 llm | bash 的 Web 栈工具,而我描述的所有问题都在“管道”部分。它失败的方式在于糟糕决策的分形性质非常有趣,但结果早已注定。
我试图将 LLM 的使用讨论与是否每个使用 LLM 的人都应该让他们的机器轻易被利用或意外擦除区分开来。附录中有关于本地 LLM 的一些简短想法。
暂且不谈安全性,让我们来看看即使 OpenCode 没有导致你“惨遭炸裂”,它作为一个工具如何也会失败。OpenCode 有一种类似 Bethesda 效应的现象,让人很难区分什么是漏洞,什么是设计本意,所以我保留了一个“令人烦恼”的描述。
大多数本地 LLM 服务器使用 OpenAI /v1/chat/completions API 的某种变体。其思路是:
你 POST 一个包含所有对话记录的 JSON 数据块。
你会收到一系列 SSE 事件的流,这些事件累积成完整的响应。
上传成本随会话呈二次增长,而下载成本会因为将微小变化包装在带有重复元数据的 JSON 中而被放大。工具调用使用难以捉摸的“双重 JSON 编码”,因此它们可以被序列化为多个 JSON 编码的增量,再重新组合成更多的 JSON。
这种设置有一个好处,就是服务器是无状态的。像往常一样,使无状态的东西运行得快的方法是:状态。服务器会缓存评估结果。当它收到请求时,会:
找到最长匹配的缓存前缀。
从前缀的末尾评估到最后一条发布消息的末尾(“预填充”)。
生成新的标记,直到遇到序列结束标记。
我在 M4 Max 上使用 Qwen3.6-27B dense,这款设备具有不错的内存带宽(约 0.5 TB/s,对于 CPU SoC 来说高,对于 GPU 来说低)。标记生成是可用的,但预填充阶段极度受计算限制。如果我的服务器在窗口深处无法找到请求提示的匹配前缀,则可能需要等待 10 分钟的 GPU 最大使用率才能开始生成响应。没关系,因为这种情况应该很少发生。应该如此。
以下是 OpenCode 在这方面忽略的一些问题:
它会遍历你的文件系统,并在每轮 SSE 中重新读取 AGENTS.md(在 turn-0 系统提示中注入)。如果你在 AGENTS.md 中放了一条快速笔记,以便在下一个会话中读取,你会立即强制进行完整的重新评估。
它会在每次代理 → 用户的转换中修剪工具调用的上下文,从而使大量前缀无效。
修剪只是丢弃写入头部固定距离 const PRUNE_PROTECT = 40_000 之外的工具调用结果。在最好的情况下,你会遇到 40k 上下文未命中,这相当于每两到三轮读取一本完整长度的小说。
代理 → 用户的转换包含中断,所以如果你需要将模型拉出“兔子洞”并重新引导它,OpenCode 会立即清空提示缓存,让你等待响应。
个人最喜欢的:它会在 turn-0 系统提示中放入当前日期,并在每轮 SSE 重新评估。如果你在午夜使用 OpenCode,你会得到完整的提示缓存未命中。
这些都是仅属于这一类别的提示缓存未命中情况。还有更多,我会在后续提及。
我在上一节中提到了修剪。提示缓存未命中的代价不值得,所以我禁用了它。另一个明显的问题是对早期读取缺乏保护。可能不明显这种情况有多么完全崩溃,所以我们来举个例子。假设你开启了一个新会话,并告诉你的 clanker 先阅读规范或实现计划,然后写一些代码:
clanker 去读取相关代码,很可能会超过固定的 40k 修剪阈值。
clanker 准备好实现,但要么立刻跳进一个愚蠢的死胡同,要么在思维链中犹豫不决,针对某个实际上非常简单或已经明确规定的东西。
你打断 clanker 来重新引导它。
打断动作使整个规范从上下文窗口中被删除。
clanker 在无法参考规范的情况下写代码。
修剪同样适用于除 skill 外的所有工具结果,而 skill 从不被修剪。
想要坐 10 分钟,让 LLM 服务器用一个新的提示填满整个会话,仅仅是为了把它变成 5 个要点,放在新会话的顶部?我也不想。我明白他们的意图,但我没见过效果好。无论是压缩还是修剪,都实现得不好,而且它们相互影响很差。
如果你想总结一个会话,那么总结提示应该在最后注入,以避免从头预填充整个会话。我发现最好的方法是通过显式交接来实现,告诉 clanker 写出笔记。这很笨拙,但它比 OpenCode 的压缩机制效果更好,并创建一个磁盘上的工件,我可以编辑或在多个会话中重复使用。
压缩是一种有漏洞的抽象,它试图让有限的上下文窗口看起来像无限的。最好接受上下文窗口和提示缓存作为 clanker 操作的一等功能,并提供更好的原语来管理它们。Pi 在这方面有一个有趣的方法,即会话树,它刻意利用提示缓存。
OpenCode 会在新上下文窗口的顶部粘贴系统提示。没问题,也很正常,但:
默认的系统提示非常冗长。讽刺的是,大部分字数都是在向LLM解释如何简洁。
默认的系统提示是有观点的(没问题),但观点很糟糕(不正常)。我花了一段时间才弄明白为什么我的经纪人在派遣分代理时总是说“绝对不要评论”。
规划到建造的切换很笨拙,常常在上下文窗口快结束时,所有内容都被充分理清了。我更愿意把讨论内容写成笔记,这样我可以编辑完再交给新的会议。对此,请参见下一点:
计划模式的系统提醒告诉 clanker 不能写入任何目录,但它实际上可以写入特定的 .opencode/plans 目录。我见过两种失败:无提示地写入该目录,或者在明确指示下拒绝写入。
无法全局修改默认系统提示;你必须把它复制到每个项目里。
如果你只在构建模式下覆盖默认提示,切换到计划模式就是完全未命中缓存。
不同的模型提示内容和质量差异极大。它们都值得我来一遍讨厌,但野兽模式(GPT-4,o1和o3)是我最喜欢的。引用:
没有通过 Google 验证你对第三方软件包和依赖的理解是否及时,你无法成功完成这项任务。
你不能。这简直不可能。我们不知道怎么做。绝对不要只看包的源代码。
当 clanker 尝试访问项目目录外的文件时,如果它以 OpenCode 通过临时字符串解析识别的方式访问(哎呀,那是令人担忧的部分),你会收到提示,询问是否授予权限。这会暂停执行,直到你做出反应。
答案是:是 / 否 / 永远。你觉得这里有缺失的答案吗?那《永不》怎么样?
这里与亚代理的互动尤其严重。如果子代理尝试访问 /tmp 中的脚本输出,而我说“否”,它会杀死子代理,其部分完成的工作的所有上下文都会丢失。所以我只能说“是”,让它写入 /tmp 或它想做的其他操作。
另一个问题是决策疲劳:如果我不断被问“我可以做这个吗?”而唯一能提高生产力的回答是“可以”,那么我最终会在某些危险的事情上点头同意。人类的易错性不应成为诸如“不要在这个目录之外写东西”这样基本规则的支撑力。
这是一个编码代理的第0号功能。它坏了。
如果我在SSE流传输进行中发送消息,它会被排队。不错的功能。然而:
OpenCode决定实际发送消息的语义有些不明确;代码显示这可能是在一次工具调用回合结束时,但我也见过工具→CoT的转换而没有发送我的排队消息。
如果我随后中断,因为我希望“clanker”实际回答我的问题而不是自我沉思,消息会从“排队”状态中移除,只是进入消息日志。你中断了为了让clanker回答你的消息,但现在你不能发送消息。你必须发送第二条消息来开始新的流。
撤销消息通常无法将其从消息日志中移除。
使用子代理(即由代理通过工具调用RPC生成的代理)问题仍然存在:
我无法与子代理交流;如果它们进入一个愚蠢的死胡同,我只能选择杀掉它们并失去它们的上下文,或者无助地看着它们消耗令牌。
我调查了一下,显然OpenCode以前有这个功能,但它就是…消失了?
你可以在主代理的聊天窗口@提及子代理,但这似乎没有任何有用作用。特别是,它不会中断。
如果子代理工具调用失败(例如 Qwen 在 CoT 中放置工具调用),那就是致命的,并且直到那时的所有上下文都会丢失。
理论上重用子代理似乎不错,但实际上子代理的主要用途是将任务拆分为更小的上下文窗口。有时代理决定为不相关任务重用同一个子代理,则会破坏这一点。
我从未见过这有益,反而常常见到它因在拥有庞大上下文的代理和子代理之间切换导致提示缓存未命中(又来了!)。
原则上:人类方面拥有更丰富的交互模型是可以的,但机械人会做出所有可能的愚蠢行为,因此需要尽量减少选择。
积极的一面是,OpenCode 的子代理交互导致了我有幸阅读的最有趣的 GitHub 问题之一。
If you don’t know what OpenCode is, imagine a boot stamping on a human face forever. The boot is made of TypeScript and the face is everything we have learned about security and systems software since the invention of the electronic computer in the 1940s. The creators:https://opencode.ai/ describe it as an AI coding agent. As far as I can tell it’s the most popular open-source coding agent, and it currently has 161k stars on GitHub.
I’ve tried out OpenCode with a local LLM. My conclusion is that OpenCode is clown-car turboslop with a security posture of “let me bend over for you daddy”. Everyone using it should stop using it.
There are two parts to this post: annoying things and alarming things . The second part is longer. I wrote this post with reference to source code from OpenCode git version baef5cd4 .
I don’t consider anything in this post to be a security disclosure. OpenCode is fundamentally a web-stack tool for piping llm | bash , and all the issues I describe are in the “pipe” part. The ways it fails are fascinating in the fractal nature of the poor decision making, but the outcome was foregone.
I tried to keep discussion of LLM use separate from whether everyone using LLMs should have their machines trivially exploited or accidentally wiped. There is a post-script with some brief thoughts on local LLMs.
Let’s put security to one side for a moment and examine how OpenCode fails as a tool even when it’s not causing you to get your shit popped. There is a kind of Bethesda Effect with OpenCode where it’s impossible to tell what is a bug and what is by design, so I stuck with a description of “annoying”.
Most local LLM servers use some variant of the OpenAI /v1/chat/completions API. The idea is:
You POST a JSON blob with the entire conversation so far.
You get back a stream of SSE events which add up to the response.
The upload cost over a session is quadratic, and download is amplified by wrapping tiny deltas in JSON with repeated metadata. Tool calls use the elusive “double JSON encoding” so they can be serialised as multiple JSON-encoded deltas that reassemble into more JSON.
The setup has one benefit, which is the server is stateless. As usual, the way you make stateless things fast is: state . The server caches evaluations. When it receives a request, it:
Finds the longest matching cached prefix.
Evaluates from end of prefix to end of the last posted message (“prefill”).
Generates new tokens until it encounters an end-of-sequence token.
I used Qwen3.6-27B dense on an M4 Max, which has decent memory bandwidth (~0.5 TB/s, high for a CPU SoC, low for a GPU). Token generation is usable but it is extremely compute-bound in prefill. If my server can’t find a good matching prefix for the request prompt when I’m deep into the window then I might have to wait 10 minutes of max GPU usage for it to start generating a response. That’s fine, because this should happen rarely. Should.
Here are some of the ways OpenCode missed the memo on this one:
It globs your filesystem and re-reads AGENTS.md (injected in turn-0 system prompt) on every SSE turn. If you put a quick note in AGENTS.md to be read in the next session, you immediately force a full re-evaluation.
It prunes context from tool calls on every agent → user transition, invalidating a large part of the prefix.
Pruning just discards tool call results more than a fixed distance const PRUNE_PROTECT = 40_000 behind the write head. In the best case you’re taking a 40k context miss, which is equivalent to reading a full-length novel per two or three turns.
Agent → user transition includes interruption, so if you need to pull the clanker out of a rabbit hole and re-steer it, OpenCode immediately trashes the prompt cache and makes you wait for a response.
Personal favourite: it puts the current date in the turn-0 system prompt and re-evaluates every SSE turn. If you’re using OpenCode at midnight you get a full prompt cache miss.
These are the prompt cache misses that fit solely in this category. There are many more; I’ll call them out as we go.
I mentioned pruning in the previous section. The prompt cache misses aren’t worth it so I disabled it. The other glaring issue is the lack of protection for early reads. It might not be obvious how completely broken this is, so let’s work through an example. Suppose you start a fresh session, and tell your clanker to first read a spec or implementation plan, then write some code:
The clanker goes and reads related code, very likely putting it over the fixed 40k pruning threshold.
The clanker is ready to implement, but either immediately dives down a dumb rabbit hole, or sits in chain-of-thought dithering about something that is actually very simple or already well-specified.
You interrupt the clanker to re-steer it.
The interruption causes the entire spec to be deleted from the context window.
The clanker writes code without being able to refer to the spec.
Pruning applies equally to all results of all tools except for skill , which is never pruned.
Want to sit for 10 minutes while the LLM server prefills the entire session with a new prompt prefixed to it, just to turn it into 5 bullet points that go at the top of a new session? Me neither. I get what they are going for, but I’ve not seen it work well. Neither compaction nor pruning is implemented well, and they interact poorly.
If you want to summarise a session then the summarisation prompt should be injected at the end to avoid prefilling the entire session from scratch. The best method I’ve found is just an explicit handoff by telling the clanker to write out notes. It’s ugly but it works better than OpenCode’s compaction mechanism, and creates an on-disk artefact that I can edit or reuse in multiple sessions.
Compaction is a leaky abstraction that tries to make a finite context window look like an infinite one. It’s better to accept context windows and prompt caches as a first-class feature of clanker wrangling, and expose better primitives for managing them. Pi has an interesting approach here with session trees, which deliberately exploit the prompt cache.
OpenCode pastes a system prompt at the top of new context windows. Fine and normal, but:
The default system prompt is incredibly verbose. Ironically most of the word count is explaining to the LLM how to be concise.
The default system prompt is opinionated (fine) but it has shit opinions (not fine). It took me a while to figure out why my agent kept saying “Use ABSOLUTELY NO COMMENTS ” when dispatching subagents.
The Plan-to-Build handover is clumsy and often leaves me towards the end of a context window by the time everything is sufficiently elucidated. I’d rather write out notes with everything discussed, so I can edit it and then hand off to a fresh session. To which, see next point:
The system reminder for Plan mode tells the clanker it can’t write to any directories, but it’s actually allowed to write to a specific .opencode/plans directory. I have seen this fail both ways: writing to this directory unprompted, and refusing to write when explicitly instructed.
There is no way to modify the default system prompt globally; you have to copy it into every project.
If you only override the default prompt in Build mode, switching to Plan mode is a full prompt cache miss.
Different per-model prompts have wildly varying contents and quality. They’re all worth a good hate-scroll but Beast Mode (GPT-4, o1 and o3) is my favourite. Quote:
You CANNOT successfully complete this task without using Google to verify your understanding of third party packages and dependencies is up to date.
You cannot. It’s just impossible. We don’t know how. Definitely don’t just read the source code for the package.
When the clanker tries to access a file outside of the project directory, if it does so in a way that OpenCode manages to recognise with ad-hoc string parsing (oops that is the alarming things section), you receive a prompt asking you whether to grant permission. This halts execution until you respond.
The answers are: Yes / No / Always . Do you see a missing answer here? How about Never ?
The interaction with subagents here is particularly broken. If a subagent tries to access a script output in /tmp , and I say No , it kills the subagent and all of its context for its partially complete work is lost. So I have to say Yes and let it write to /tmp or whatever it’s trying to do.
The other issue is decision fatigue: if I keep getting asked “can I do this?” and the only response that leads to productivity is “yes” then I’m eventually going to nod through something dangerous. Human fallibility should not be load-bearing for something as basic as “don’t write outside this directory”.
This is feature number 0 for a coding agent. It’s broken.
If I send a message while SSE streaming is ongoing, it gets queued. Nice feature. However:
The semantics of when OpenCode decides to actually send the message are a little unclear; the code suggests it’s at the end of a tool call turn but I have also seen tool → CoT transitions without sending my queued message.
If I subsequently interrupt because I want the clanker to actually answer my question instead of navel gazing, the message is removed from the “queued” state and just goes into the message log. You interrupted to get the clanker to answer your message, but now you can’t send the message. You have to send a second message to start a new stream.
Undoing a message often fails to remove it from the message log.
The problems continue with subagents (i.e. agents spawned by an agent using a tool call RPC):
I can’t talk to subagents; if they go down a dumb rabbit hole I have a choice of killing them and losing their context, or helplessly watching them burn tokens.
I looked into this and apparently OpenCode used to have this feature but it’s just… gone?
You can @mention a subagent from the main agent’s chat window but this doesn’t seem to do anything useful. In particular it doesn’t interrupt.
If a subagent fails a tool call (e.g. Qwen putting tool calls in CoT) then it’s fatal and all the context up to that point is lost.
The ability to reuse subagents seems nice in theory but in practice the main use of subagents is to break tasks into smaller context windows. Having the agent sometimes decide to reuse the same subagent for an unrelated task defeats this.
I have never seen this be beneficial and often seen it cause prompt cache misses (there it is again!) due to switching between agents and subagents that both have huge contexts.
As a principle: it’s ok to have a richer interaction model from the human side, but clankers will do every possible dumb thing, so choices need to be minimised.
On the positive side, OpenCode’s subagent interactions led to one of the funniest GitHub issues I have ever had the pleasure of reading.