Dark Dwarf Blog background

Agent 会话管理的原理

Agent 会话管理

在前面的文章中我们提到:nonoka cli 每轮对话 spawn 一个全新进程,进程跑完就自己关掉了。初步接触这个流程的人可能会有下面的疑惑:“进程死了,内存里的执行状态全没了。新进程是从头开始跑入口代码的,它凭什么知道接着上次聊?怎么知道该拿哪个 checkpoint?”

弄清这个问题需要明确下面三个东西:

  1. 代码在磁盘上(Python 源文件,随时可以重新启动);
  2. 执行状态在 SQLite 里(checkpoint 快照,进程死了它还在);
  3. 唯一需要跨进程传递的是一个短字符串——session_id。前两个都不怕进程死,这个才是需要拿到的。

1. Session ID 的存储

session_id 本身就是一个 uuid 字符串,它会被 nonoka opencode provider 落盘到下面的地方:

// packages/nonoka-opencode-provider/src/nonoka-language-model.ts
export function loadChatSessionId(cwd: string): string | undefined {
  const file = getChatSessionIdFile(cwd); // <cwd>/.nonoka/provider-session.id
  if (!existsSync(file)) return undefined;
  const id = readFileSync(file, "utf-8").trim();
  return id || undefined;
}

export function saveChatSessionId(
  cwd: string,
  sessionId: string | undefined,
): void {
  // ...
  fs.mkdirSync(path.dirname(file), { recursive: true });
  writeFileSync(file, sessionId, "utf-8");
}

模型实例构造时加载:

this.chatSessionId = settings.sessionId ?? loadChatSessionId(config.cwd);

前面提到过进程间通过 NDJSON bridge 协议来传递信息,ChatRequest 携带 session_id 字段;响应方向,CLI 在第一次成功响应时发出 session_init 事件把新会话的 id 交给 provider:

# src/nonoka_cli/bridge/handler.py
# Provider explicitly requested a brand-new nonoka session (e.g. /new).
if msg.new_session:
  new_id = await self._orchestrator.new_session()
  self._session_id = new_id
  self._session_init_sent = False

# Use provided session_id or the orchestrator's current session.
await self._apply_session(msg.session_id)

# Let the provider know the session id on the first successful response.
if not self._session_init_sent and self._session_id:
  await self._send(SessionInitEvent(session_id=self._session_id))
  self._session_init_sent = True

provider 收到 session_init 后写文件:

onSessionInit: (sessionId) => {
  if (isTitle) { this.titleSessionId = sessionId; return; }  // 标题生成不污染 chat session
  this.chatSessionId = sessionId;
  saveChatSessionId(this.config.cwd, sessionId);
},

2. 是否为新 Session 的决定

“要不要新会话”的判断不在一个地方,而是 provider 和 CLI 各判断一次,判据完全不同:

  • provider 侧看消息历史nonoka-language-model.tsisNewConversation):OpenCode 的 /new 会把消息历史重置成 system + user 两条,所以只要 prompt 里没有 assistant / tool 消息,就判为新会话;若有,就判为继续:
private isNewConversation(options: LanguageModelV3CallOptions): boolean {
  // OpenCode's /new resets the message history to system + user only.
  for (const message of options.prompt) {
    if (message.role === 'assistant' || message.role === 'tool') {
      return false;
    }
  }
  return true;
}

判为新会话时 chatSessionId = undefined(清掉手里的 session_id),请求里 session_id 就为空;判为继续时,session_id 取自文件里读到的 id。

注意:.nonoka/provider-session.id 文件在不在,不参与这个判断,它只是 session_id 的持久化拷贝,用来跨进程恢复 SessionState 的。

  • CLI 侧看请求:如果 new_session: true 就强制新建;如果没有 session_id 就用”当前会话“。由于每个请求都是 spawn 的新 nonoka 进程、默认的新 uuid,所以没有 session_id 就默认新建;如果带了但数据库查不到,就降级新建并记录 session_not_found_starting_new warning。

这种读取方式会导致一个危险的组合:场景是续聊(历史里有 assistant)但文件丢了。provider 不认为这是新对话,可请求里又没有 session_id,CLI 只好新建 Session。但是用户又以为在接着聊,实际旧会话已经躺在库里成了孤儿。

一个简单的解决方案是:让每个请求自带一个“能重新算出”的指纹,id 丢了就按指纹找回

  1. provider 算指纹:每个 chat 请求附带 conversation_key首条 user 消息文本的 sha256。这个选择很关键:OpenCode 每轮传的是全量历史,所以首条 user 消息在整段对话中不变,指纹天然稳定;而且它是即时计算的、不依赖任何持久化的完整性,id 文件丢了也不影响。
// packages/nonoka-opencode-provider/src/nonoka-language-model.ts
private computeConversationKey(prompt): string | undefined {
  // Fingerprint the conversation by its first user message.
  for (const message of prompt) {
    if (message.role !== 'user') continue;
    const text = extractText(message.content);
    if (!text) return undefined;
    return createHash('sha256').update(text, 'utf8').digest('hex');
  }
  return undefined;
}
  1. CLI 存指纹:会话创建时把 conversation_key 写进 cli_sessions.metadata,查找用 json_extract(metadata, '$.conversation_key') = ? ORDER BY last_active DESC LIMIT 1,同一个指纹命中最近活跃的会话。命中就 switch_session 切回去,并把 session_init 事件标成 recovered: true + recovery_reasonsession_id_missing / session_not_found),provider 收到后打日志、重新持久化 id:
# handler.py 的核心逻辑(简化)
if not session_id:
  match = await self._find_recovery_candidate(conversation_key)
  if match is not None:
    await self._try_switch(match.session_id)
    self._mark_recovered("session_id_missing", match.session_id)
  elif self._has_continuation_messages(msg):
    logger.warning("session_id_missing_starting_fresh",
                   recent_sessions=await self._recent_sessions_summary())
  1. 兜底保留:指纹也找不到、但历史证明这是续聊时,返回 session_id_missing_starting_fresh 警告并列出最近活跃会话。