在把一个AI编程助手从“能跑”改造成“敢让团队一起用”的过程中,我花掉的时间远远超出了预期。这里的核心难点不是模型调用,也不是提示词,而是整个系统如何管理“会话”和“历史”。我当时的方案是照着opencode的思路重新设计:把交互会话与执行会话彻底拆分,再把所有状态变化都落到事件流里。这套组合解决了大量并发、回放、审计问题。这篇文章是上篇,重点讲清工程全景、双会话内核和事件溯源三个核心设计。
无论你是在自己写编码代理,还是想优化现有的一站式AI工具,这篇文章都应该能帮上忙。我会把每个关键选择的背后动机、数据结构、调度逻辑都摊开讲,最后附上一条可直接参考的简化实现路径。不绕弯子,直接从工程全景说起。
1. 工程全景:先看清整张地图
1.1 为什么opencode会采用“双会话+事件溯源”的组合
很多人在做编码代理时,下意识就是“一个对话框对应一次会话,会话里存消息列表”。这个模型在最简单的场景下没问题,但一旦涉及工具调用、代码修改、多轮上下文维护,麻烦就来了。
我举个实际场景:你在终端问模型“帮我查一下昨天构建失败的原因”,模型开始分析日志,然后它要执行几个命令来复现问题。这期间你的终端界面还在等待模型输出结果。如果这个会话只有一个上下文,那么模型在等待工具执行时,整个交互就卡住了。你没法同时输入“顺便把日志里最近十行贴给我”,也没法在另一个终端窗口里单独请求一个总结。
opencode的“双会话内核”就是来解决这个问题的。它把模型交互和工具执行分成了两个并行的会话域,一个负责“理解对话内容并生成回复”,一个负责“执行外部工具并收集结果”。与此同时,所有会话的变更都作为事件被追加到日志里。这样无论你想回放、断电恢复还是审计,都能拿同一份数据源来重建现场。
事件溯源的引入不是追时髦,而是自然演化。当一个系统里有多个会话域并行运作,如果还是靠“保存最新状态”这种快照式写法,必然会遇到状态不同步、误覆盖、无法追溯的问题。改成事件流之后,每个状态变化都是一个不可变事实,谁在什么时间改了哪个文件、调了什么工具,一目了然。
提示:事件溯源不是银弹,但凡是需要多进程/多线程协作、且对可追溯性和故障恢复有要求的系统,它的收益会远大于成本。
1.2 各模块划分与职责边界
opencode的工程代码可以拆成六个核心模块,每一层职责都很纯粹:
| 模块 | 职责 | 关键接口 |
|---|---|---|
| CLI入口层 | 处理终端参数、启动交互界面、加载配置文件 | run_cli() |
| 会话内核 | 维护双会话状态、分发事件、调度模型和工具调用 | SessionKernel.dispatch() |
| 模型适配层 | 统一接入不同模型服务(内部服务或自托管),处理流式与非流式响应 | ModelAdapter.stream() |
| 工具沙箱 | 隔离并执行文件读写、Shell命令、代码搜索,返回结构化结果 | ToolSandbox.execute() |
| 事件存储 | 追加事件、读取事件流、生成快照、支持事件流订阅 | EventStore.append() |
| 状态投影器 | 从事件流重建当前会话状态和上下文窗口 | Projector.project() |
这套划分最关键的思路是:会话内核和工具沙箱之间不直接共享内存对象,而是通过事件存储进行“间接通信”。举个例子,工具沙箱完成了一个Shell命令执行,它不会直接告诉会话内核“结果在这里”,而是追加一个ToolResult事件。会话内核通过订阅事件流拿到这个结果,再喂给模型适配层。
这样做初看起来多绕了一层,但带来的好处是:如果工具沙箱崩溃了,事件流里还有记录,重启后可以继续。如果模型输出异常,你也可以从事件流里回放,看是哪个时间点开始跑偏的。
1.3 一次完整请求的数据流全景
我用一次“让模型帮我重构某个函数”的请求,把完整数据流串一遍,这样更容易理解各个模块是怎么协作的。
- 用户在CLI输入一条消息,CLI层将它封装成
UserMessage事件并追加到事件存储。 - 会话内核从事件存储里订阅到新的
UserMessage,更新用户会话的上下文,然后向模型适配层发起生成请求。 - 模型适配层返回一个流式计划,里面包含多个
ToolCall建议。这些建议被会话内核拆解,逐条封装成ToolCallRequested事件。 - 会话内核把
ToolCallRequested事件投递给工具沙箱,工具沙箱按隔离策略执行,比如读取文件或运行测试命令。 - 工具沙箱执行完毕后,追加
ToolResult事件。此时执行会话与用户会话是分离的,用户侧仍然可以自由发送新消息,不会被工具执行卡住。 - 事件存储持续广播新事件,会话内核和状态投影器各自消费。投影器负责把最新状态更新到内存缓存,供CLI渲染界面。
- 模型拿到
ToolResult后再生成最终回复,产生AssistantMessage事件,CLI将其流式展示给用户。
这个流程里,双会话体现在哪?用户会话管“人机对话的上下文”,执行会话管“工具调用与结果收集”。它们都订阅同一份事件流,但不直接互相等待。你可以在模型还在思考的同时,并行发出另一个轻量请求,它走的还是同一条事件总线。
2. 双会话内核:为什么拆,怎么拆
2.1 用户会话与执行会话的对象模型
从代码层面看,“会话”不是一个简单的消息数组,而是一个领域对象。opencode里有两个核心类,一个是UserSession,一个是ExecutionSession,它们继承自同一个BaseSession。
@dataclass class BaseSession: session_id: str version: int created_at: datetime updated_at: datetime @dataclass class UserSession(BaseSession): # 用户会话只“关心”人机对话上下文 messages: list[Message] current_mode: str # "idle" | "thinking" | "awaiting_tool" @dataclass class ExecutionSession(BaseSession): # 执行会话维护工具调用的生命周期 active_tools: dict[str, ToolInvocation] last_result: ToolResult | None为什么把它们分开?因为生命周期不同。用户会话的生命周期通常持续整个任务,而执行会话内部可能一个工具调用就结束了,或者多个工具调用并行。如果混在一个会话里,状态机很难管理。比如模型发起了三个并行工具调用,其中两个成功,一个超时,此时应该只有执行会话进入“超时处理”状态,用户对话应该继续保有上下文。
内核调度器维护着一张路由表,根据事件类型决定它应该进入哪个会话域。UserMessage进用户会话域,ToolResult进执行会话域。事件流作为它们之间的“公共记忆”,解决了跨会话数据交换问题。
2.2 并发隔离:双会话到底是怎么并行跑的
光有对象模型还不够,进程里得真正有两条执行路线。opencode采用的是“两个独立的事件循环 + 一个共享队列”的模型。
浏览器厂商都明白这个概念:用户输入和页面渲染放在不同线程,防止卡顿。opencode也一样。用户会话所在的事件循环负责接收键盘输入、渲染内容、处理模型流式输出;执行会话所在的事件循环负责执行Shell命令、处理文件系统变更、等待外部进程退出。
这两个事件循环之间通过一个ConcurrentQueue交换消息。消息不是裸数据,而是事件引用。执行循环把执行结果对象放入队列,用户循环从队列取出后追加成ToolResult事件。这样即使工具执行耗时很长,也不会阻塞用户输入。
我在设计的时候特意避免了直接用锁来同步状态。一旦多个会话域之间开始用互斥锁,死锁问题就会层出不穷。opencode的正确做法是“单写者原则”:每个事件类型定义一个写入方,其他域只能订阅或消费。比如ToolResult只有工具沙箱能写入,AssistantMessage只有模型适配层能写入,这样就消除了写冲突。
注意:如果你在自己的项目里做双会话,请务必遵守“一个事件类型只有一个写者”的约束。谁都能写,状态就乱套了。
2.3 状态机与切换时机
双会话的内核不是从头到尾都一个状态。每个会话都有自己的状态机,这些状态机之间通过事件进行跃迁。
用户会话的状态比较简单:idle→thinking→awaiting_tool→idle。当模型开始出内容时,进入thinking;当系统检测到需要执行工具时,进入awaiting_tool;等工具结果回来并交给模型后,回到idle等待下一次输入。
执行会话的状态相对复杂,它要管理工具调用的整个生命周期:pending→running→succeeded/failed/cancelled。当它收到一个ToolCallRequested事件时,从pending变成running。工具返回后,根据退出码和输出内容写入不同终态。
这里有个容易踩坑的细节:用户会话在awaiting_tool状态下,并不意味着不能接收新消息。新消息可以进入一个“待处理队列”,等当前工具调用完成后再进入模型。这就是双会话带来的体验提升——你不需要傻等。
我实际测过,在执行长命令时,用户依然可以继续输入新指令,比如“等命令跑完顺便统计一下输出行数”。这份指令不会丢失,而且会作为后续上下文的一份子进入下一轮模型请求。
3. 事件溯源:把历史变成可回放的资产
3.1 事件类型与存储设计
事件溯源的核心是“把每一次状态变化本身保存下来”,而不是只保存最终状态。在opencode中,一条事件记录长这样:
{ "event_id": "evt_8f3a...", "aggregate_id": "sess_user_01H...", "event_type": "UserMessage", "version": 41, "timestamp": "2025-03-17T09:12:33.542Z", "payload": { "content": "帮我把main函数里的重复逻辑提取出来", "source": "cli" } }事件类型我维护了一个枚举类,常用类型有:
SessionCreated:创建会话UserMessage:用户输入AssistantMessage:模型输出ToolCallRequested:请求调用工具ToolResult:工具执行结果FileChanged:文件系统变更SessionEnded:会话关闭
因为每种事件类型都含有version字段,我们很容易检查事件的连续性。比如用户会话当前版本是41,下一个收到的却是43,那说明中间丢了事件,这通常是存储层写入失败或订阅端消费落后导致的,可以立刻触发告警。
事件存储层我推荐用追加式日志,最朴素的方式就是JSON Lines文件,每行一个事件。生产环境中可以换成SQLite或PostgreSQL,但核心设计不变:只允许追加,不允许原地修改。
3.2 状态重建与快照策略
状态重建是事件溯源的关键能力。假如系统崩溃了,重新启动后,你不用依赖内存里的旧状态,而是打开事件流,从第一条事件逐条重放到最新一条,即可得到当前完整状态。
原理上很简单,就是归约函数:
def project(events: list[Event]) -> SessionState: state = SessionState.empty() for event in events: state = apply_event(state, event) return state实际操作中,如果事件量很大,逐条重放会越来越慢。opencode的解决办法是有状态快照(Snapshot),每隔N个事件或在关键节点记录一次快照,重放时从最近快照开始,而不是从头开始。快照本质上就是一个普通的StateSnapshot事件,追加到事件流末尾。恢复时找到最后一个快照事件,从那个位置往后再重放。
一个有效的快照策略是“双阈值”:既按事件数量(比如每200条)触发,也按时间间隔(比如每5分钟)触发,确保长会话和短会话都能控制恢复时间。
提示:快照本身就是事件,意味着它也被审计。这比传统的“每隔一段时间导出全量状态”要干净得多,不会有两种数据模型。
3.3 事件溯源的撤销/重做与协作复现
我在opencode里用得最舒服的地方是撤销/重做功能。传统实现撤销是基于状态栈,每次改动保存一个状态副本,占用大量内存。事件溯源完全不同:撤销就是从事件流中删除该事件吗?并不是。正确的做法是在事件流里追加反向事件。
比如原来有一个FileChanged事件,内容是从旧内容A改成新内容B。撤销时会追加一个FileChanged事件,内容是从B改回A。这两个事件都会保留在日志里,形成一个完整的“变更记录”。这样回放任何时刻,你都能精确知道文件内容是什么,而不是只知道最终状态。
协作场景下,多个开发者共同操作同一个opencode实例时,事件溯源的优势更加明显。每个人看到的界面不是实时共享内存,而是各自订阅事件流,再投影到本地状态。由于事件顺序是一致且可追溯的,两条并发操作就不会出现“后写覆盖前写”那种懵圈情况。谁先改、谁后改,全在事件流时间戳里。
4. 实操:构建一个简化版的“双会话+事件溯源”内核
4.1 最小依赖与基础数据结构
如果你照本文思路实现自己的内核,不需要一上来就引入全套框架。我建议的最小技术栈包括:
- Python 3.12+,使用
asyncio处理双事件循环 - SQLite或纯文件存储事件日志
pydantic做事件类型的校验
代码里需要先定义基础事件类和存储接口。我用一个轻量的方式实现:
from pydantic import BaseModel from enum import Enum from datetime import datetime, timezone class EventType(str, Enum): SESSION_CREATED = "SessionCreated" USER_MESSAGE = "UserMessage" ASSISTANT_MESSAGE = "AssistantMessage" TOOL_CALL_REQUESTED = "ToolCallRequested" TOOL_RESULT = "ToolResult" class Event(BaseModel): event_id: str aggregate_id: str event_type: EventType version: int timestamp: datetime payload: dict事件存储的接口我定义为三个方法:append(event)追加事件,read_events(aggregate_id, after_version)读取某个会话从某版本之后的事件,get_snapshot(aggregate_id)获取最近快照。
4.2 会话内核与双事件循环实现
双会话的核心是一个消息分发器。我用asyncio.Queue让它跑在两个独立任务上。用户事件循环拿键盘输入投递事件,执行事件循环等待工具结果事件。
class SessionKernel: def __init__(self, store: EventStore): self.store = store self.user_queue = asyncio.Queue() self.exec_queue = asyncio.Queue() self.sessions: dict[str, SessionState] = {} async def user_loop(self): while True: event = await self.user_queue.get() await self.handle_user_event(event) async def exec_loop(self): while True: event = await self.exec_queue.get() await self.handle_exec_event(event)注意两个队列里的元素类型相同,但消费逻辑不同。handle_user_event负责更新用户会话上下文并调用模型接口,而handle_exec_event负责更新执行会话状态并把结果回投给用户会话。唯一需要同步的地方是状态投影器,它负责把事件应用到本地快照。这一步我使用一个单独的异步锁,避免同一会话的多个事件同时被应用。
4.3 事件存储与回放工具的命令实现
最后一块是事件存储。我用的实现是追加到SQLite时,用单一events表,包含event_id、aggregate_id、event_type、version、timestamp、payload六个字段。写入时使用单条事务,确保事件不丢失。
回放工具就是一个命令行脚本,读取指定会话的事件流,用project()重建状态,并且打印每一步操作。我经常用它来分析“模型为什么会做出某个决定”。只要打开事件流,就能看到模型在哪个时间点收到了哪个工具结果,然后作了什么判断。这种排查效率和以前“看日志、猜状态”完全不是一个级别。
5. 常见问题与排查技巧实录
5.1 执行会话迟迟不结束,用户会话被“卡死”的错觉
有段时间我收到反馈,说“执行命令的时候,输入框不能打中文”。一开始以为是终端库的问题,后来排查发现,是用户会话状态机设置错了:当执行会话处于running时,用户会话也被切到了awaiting_tool,并且CLI直接禁用了输入框。实际上是双会话没用好——用户侧的输入应该始终可用,指令进入待处理队列即可。
修正方法:用户会话的输入事件和工具等待事件解耦,输入永远可以追加消息,但模型请求需要在当前执行完成后才发起。
5.2 事件存储无限膨胀,启动越来越慢
这是事件溯源最经典的问题。一开始我的快照策略是按事件数量触发,但数量阈值设得太大,导致会话跑到几千条事件时,启动重放明显变慢。后来改成“数量+时间”双阈值,并每天做一次全量压缩(把历史事件合并成快照,并删除已被快照覆盖的旧事件)。但要注意,压缩策略必须保留审计需求,不能直接把所有历史删掉,否则回溯能力就没了。
5.3 并发写入导致版本号错乱
使用多线程写入事件存储时,如果不加锁,很容易出现两个事件写入同一条会话,版本号相同,回放时状态就会错乱。解决方案是在每个会话的聚合根上加一个写锁,或依赖数据库的唯一约束(比如aggregate_id + version唯一)。我用的是SQLite的UNIQUE(aggregate_id, version)联合约束,写入冲突时直接抛出异常,而不是默默覆盖。
5.4 排查事件流不一致的实用技巧
排查这类问题,我一般三步走:
- 先看
events表里是否有相同(aggregate_id, version)的重复记录。 - 再写一条查询,检查每个聚合ID的版本号是否严格递增,有没有跳跃或回退。
- 最后用回放工具把事件流重放一遍,对比内存状态和数据库记录是否一致。
这三步几乎覆盖90%的事件溯源问题。
6. 一点个人体会
把双会话内核和事件溯源真正落地之后,opencode的并发能力和可维护性都上了一个台阶。最直观的变化是,长工具调用不再阻塞交互,团队里每个人都能通过回放事件流去复盘一次失败的操作过程。如果你正准备改造自己的编码代理,我建议先从事件溯源入手,先把所有状态变化落成不可变事件,再在此基础上设计双会话,你会发现后面的路顺很多。这个小技巧,比你优化提示词值钱多了。