☰
跨CLI Agent会话接力:本地状态解析与上下文传递实战
2026/10/1 3:20:14 网站建设 项目流程

1. 为什么"会话接力"是跨 CLI Agent 协作里最被低估的一环

用命令行跑 AI 编程 Agent 的人,大多经历过这样一个瞬间:在 Claude Code 里跟它来回聊了十几轮,需求背景、代码约束、踩过的坑全都喂进去了,结果因为某个任务更适合换一个 Agent 来干——比如让 Codex 去做一段它更擅长的重构,或者反过来——你只能把上下文复制粘贴过去,然后眼睁睁看着新 Agent 从零开始理解你的项目。更糟的是,等你切回来,原来那个会话的上下文窗口已经塞满,或者干脆因为进程退出而丢失了。

这就是跨 CLI 编程 Agent 的本地会话接力要解决的问题。说白了,它指的是:在多个命令行 AI 编程工具(Claude Code、Codex CLI 这类)之间,把同一个开发任务的会话状态、上下文、甚至中间产物,在本地做一次可控的传递和续接,让"换工具"这件事不再等于"重新开始"。

我之所以觉得这件事被低估,是因为大部分人把注意力放在"哪个 Agent 更强"上,却忽略了真实开发里 Agent 是交替使用的。一个典型场景:你用 Claude Code 做需求梳理和方案设计,因为它对长上下文和自然语言意图的把握更稳;然后切到 Codex CLI 去执行具体的代码修改和批量重构,因为它在代码生成和文件操作上更利落。如果没有会话接力,这个切换过程本身就是巨大的效率损耗。

这篇文章适合三类人:一是已经在日常开发里用 CLI Agent、但还在"手动复制粘贴上下文"的开发者;二是想搭一套自己的多 Agent 协作流程、但不知道从哪下手的人;三是单纯好奇"本地会话到底存在哪、能不能被程序化操作"的技术爱好者。我会从会话的物理存储讲起,一路讲到可复现的接力脚本,中间穿插我自己踩过的坑。

需要先说明一点:下面涉及的具体路径、文件格式、命令参数,都是基于当前主流 CLI Agent 的常见实现方式做的合理还原。不同版本、不同操作系统会有差异,你在实操时以自己环境里的实际输出为准,但思路和排查方法是可以直接迁移的。

2. 先搞清楚会话到底存在哪:本地状态文件的结构拆解

2.1 CLI Agent 的会话不是"内存里的对话",而是落盘的

很多人有个误解,以为 CLI Agent 的对话历史只活在当前进程的内存里,进程一关就没了。实际上,主流 CLI Agent 为了支持"继续上次会话"这种功能,都会把会话状态持久化到本地磁盘。这是会话接力能成立的物理前提——如果状态只在内存里,你根本无从接力。

以常见的实现为例,会话数据通常落在用户主目录下的一个隐藏配置目录里,结构大致是这样:

~/.<agent-name>/ ├── config.json # 全局配置:模型、API 端点、默认参数 ├── sessions/ # 会话目录,每个会话一个文件或一个子目录 │ ├── <session-id>.json │ └── <session-id>/ │ ├── messages.jsonl # 逐条消息,JSON Lines 格式 │ └── metadata.json # 会话元信息:创建时间、工作目录、模型 └── history/ # 命令历史、输入历史

这里有几个关键点值得展开。第一,会话 ID 通常是 UUID 或时间戳派生,它是你接力的"锚点"。第二,消息往往用 JSONL(每行一个 JSON 对象)存储,而不是一个大 JSON 数组——这样做的好处是追加写入成本低,进程崩溃时也不会损坏整个文件。第三,metadata 里通常记录了会话的工作目录(cwd),这一点极其重要,后面讲接力时会专门说。

2.2 消息记录里到底存了什么

打开一个 messages.jsonl,你会看到类似这样的结构(字段名因工具而异,但语义大同小异):

{"role":"user","content":"帮我把 utils 里的日期处理抽成独立模块","ts":1710000000} {"role":"assistant","content":"好的,我先看一下 utils 目录结构...","ts":1710000001,"tool_calls":[{"name":"read_file","args":{"path":"src/utils.ts"}}]} {"role":"tool","content":"export function formatDate(...) {...}","ts":1710000002}

理解这个结构对会话接力至关重要,因为接力的本质就是"把 A 的 messages 转换成 B 能吃的格式,然后喂给 B"。不同 Agent 的字段命名、角色定义、工具调用表示方式都不一样,这就是接力工作量的主要来源。

提示:在动手写接力脚本前,先手动打开一两个会话文件看看真实结构。不要凭猜测写解析代码,字段名和嵌套层级经常和文档描述不一致。

2.3 为什么"工作目录"是接力的隐形杀手

我踩过最深的坑就在这里。有一次我把 Claude Code 的会话接力到另一个 Agent,上下文全都传过去了,但新 Agent 一上来就找不到文件,报了一堆路径错误。排查了半天才发现:原会话的 cwd 是/Users/me/project-a,而新 Agent 启动时的 cwd 是/Users/me。所有相对路径全部失效。

所以会话接力里,cwd 必须作为一等公民对待。接力时要么在新 Agent 启动前cd到原会话的 cwd,要么在传递的上下文里显式声明"当前工作目录是 X"。metadata.json 里如果存了 cwd,一定要读出来用上。

3. 跨 Agent 接力的核心难点:格式鸿沟与语义损耗

3.1 三种典型的格式鸿沟

不同 CLI Agent 之间做会话接力,难点不在"搬运数据",而在"翻译语义"。我把常见的鸿沟归成三类:

鸿沟类型具体表现影响
角色定义差异A 用assistant,B 用ai或model消息被丢弃或报错
工具调用表示差异A 用tool_calls数组,B 用内联 XML 标签工具调用历史丢失
系统提示差异各自有内置 system prompt,不接受外部注入行为风格突变

第一类最好解决,写个映射表就行。第二类最麻烦,因为工具调用的历史如果丢失,新 Agent 就不知道"之前读过哪些文件、执行过哪些命令",会重复劳动甚至做出错误判断。第三类最隐蔽,你没法完全控制,只能通过把关键约束"复述"进第一条 user 消息来缓解。

3.2 语义损耗:接力不是无损复制

必须接受一个现实:跨 Agent 接力一定是有损的。原因很简单,每个 Agent 的上下文管理策略不同,有的会做摘要压缩,有的会丢弃旧的工具输出,有的对 system prompt 有强控制。你不可能把 A 的完整内部状态 1:1 还原到 B。

所以正确的目标不是"无损复制",而是"保留决策所需的最小充分信息"。具体来说,接力时优先保留这几类内容:

  • 任务目标与约束:用户最初的需求、明确的限制条件(比如"不要引入新依赖")。
  • 已达成的关键结论:比如"确认用方案 B,因为方案 A 有并发问题"。
  • 未完成的待办:明确告诉新 Agent"接下来要做什么"。
  • 重要的文件路径与改动:哪些文件被读过、被改过。

而像中间那些"我看看""好的我这就做"的寒暄、重复的工具输出,完全可以压缩掉。这其实和人类交接工作是一个道理——你不会把聊天记录全文转发给同事,而是给一份要点。

3.3 一个反直觉的经验:接力时"少即是多"

我一开始做接力,恨不得把原会话所有消息都塞给新 Agent,觉得信息越全越好。结果适得其反:新 Agent 被大量冗余历史淹没,反而抓不住重点,甚至因为上下文太长触发了它自己的压缩逻辑,把关键信息压没了。

后来我改成结构化摘要 + 最近 N 轮原文的混合策略:把早期对话压成一段结构化的"任务简报",只保留最近几轮的原始消息(因为最近的往往包含最具体的操作上下文)。实测下来,新 Agent 的接续质量明显提升。这个策略后面第 5 节会给具体实现。

4. 动手搭一套可复现的本地接力流程

4.1 整体设计:三个模块,一条数据流

我把整套流程拆成三个模块,职责清晰,方便你按需替换:

  1. 采集器(Collector):从源 Agent 的会话目录里读出指定会话,解析成统一的中间格式。
  2. 转换器(Transformer):把中间格式转成目标 Agent 能接受的输入,同时做摘要压缩。
  3. 注入器(Injector):把转换结果以合适的方式喂给目标 Agent,并处理好 cwd、启动参数。

数据流是单向的:源会话文件 → 中间格式 → 目标输入 → 目标 Agent。中间格式是关键,它让你不用为"每两个 Agent 之间"都写一套转换,而是"每个 Agent 各写一个适配器"。

4.2 定义统一的中间格式

中间格式我建议用最简单的结构,别过度设计:

# intermediate.py from dataclasses import dataclass, field from typing import List, Optional @dataclass class Turn: role: str # "user" | "assistant" | "tool" content: str tool_name: Optional[str] = None tool_args: Optional[dict] = None @dataclass class Session: session_id: str cwd: str model: str turns: List[Turn] = field(default_factory=list) created_at: float = 0.0

这个结构刻意做得扁平。role只保留三种语义角色,工具调用信息挂在 Turn 上而不是单独建对象。这样无论源格式多复杂,解析完都收敛到这几个字段,转换器写起来就轻松了。

4.3 采集器:解析源会话文件

采集器的核心是"容错解析"。会话文件可能因为进程异常退出而残缺(最后一行 JSON 不完整),所以逐行解析时一定要 try/except:

import json from intermediate import Session, Turn def collect(session_file: str, cwd: str, model: str) -> Session: sess = Session(session_id=session_file, cwd=cwd, model=model) with open(session_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: # 残缺行直接跳过,不要让整个接力失败 continue role = normalize_role(obj.get("role", "")) if role is None: continue sess.turns.append(Turn( role=role, content=obj.get("content", ""), tool_name=obj.get("tool_name"), tool_args=obj.get("tool_args"), )) return sess def normalize_role(raw: str): mapping = {"user": "user", "human": "user", "assistant": "assistant", "ai": "assistant", "model": "assistant", "tool": "tool", "function": "tool"} return mapping.get(raw.lower())

注意normalize_role这个映射表——这就是前面说的"角色定义差异"的解法。你每接入一个新 Agent,就往这张表里加几行,成本极低。

4.4 转换器:摘要压缩 + 目标格式生成

转换器分两步。第一步做摘要压缩,第二步生成目标格式。

摘要压缩我用的策略是:保留第一条 user 消息(任务原始需求)+ 保留最近 K 轮 + 中间部分用规则提取关键句。规则提取不需要调用模型,简单粗暴但有效:

def compress(sess: Session, keep_recent: int = 6) -> Session: turns = sess.turns if len(turns) <= keep_recent + 1: return sess head = turns[:1] # 原始需求 recent = turns[-keep_recent:] # 最近上下文 middle = turns[1:-keep_recent] # 从中间部分抽取"含结论性关键词"的消息 keywords = ["决定", "确认", "改为", "注意", "不要", "必须", "问题", "报错"] picked = [t for t in middle if t.role == "user" and any(k in t.content for k in keywords)] brief = Turn( role="user", content="[历史摘要] 以下是之前会话的关键结论:\n" + "\n".join(f"- {t.content[:200]}" for t in picked[:10]) ) sess.turns = head + [brief] + recent return sess

这段代码里keywords列表是我根据中文开发对话的习惯总结的,你可以按自己的语言习惯调整。[:200]的截断是为了防止单条消息过长把上下文撑爆。

第二步生成目标格式。假设目标 Agent 接受一个"初始 prompt 文件",那就把 Session 渲染成纯文本:

def render_for_target(sess: Session) -> str: lines = [f"# 接续会话(原工作目录:{sess.cwd})", ""] for t in sess.turns: prefix = {"user": "用户", "assistant": "助手", "tool": "工具输出"}[t.role] lines.append(f"## {prefix}") lines.append(t.content) if t.tool_name: lines.append(f"(调用工具:{t.tool_name} 参数:{t.tool_args})") lines.append("") lines.append("## 你的任务") lines.append("请基于以上上下文继续完成未完成的工作,不要重复已完成的部分。") return "\n".join(lines)

4.5 注入器:把上下文喂进去并处理 cwd

注入这一步,不同 Agent 的启动方式不同。有的支持--resume <session-id>,有的支持从文件读初始 prompt,有的只能通过 stdin 管道。我一般用最通用的方式——生成一个临时 prompt 文件,然后通过启动参数或管道传入:

# 生成接力 prompt python relay.py --from ~/.claude/sessions/abc.json \ --to codex \ --out /tmp/relay_prompt.md # 切到原工作目录,再启动目标 Agent cd "$(python relay.py --print-cwd --from ~/.claude/sessions/abc.json)" codex < /tmp/relay_prompt.md

这里--print-cwd是个小设计,专门用来把源会话的 cwd 单独取出来,方便在 shell 里cd。别小看这一步,前面说过,cwd 不对,后面全白搭。

注意:临时 prompt 文件里可能包含你的代码片段和路径信息,用完记得清理,别留在/tmp里过夜。

5. 实测中的意外情况与排查链路

5.1 症状一:新 Agent 完全无视接力上下文

第一次跑通脚本时,我遇到的情况是:prompt 文件明明生成了,内容也对,但新 Agent 启动后像没看见一样,直接问"你想做什么"。排查链路是这样的:

  1. 先确认文件真的被读进去了。在目标 Agent 启动命令里加--verbose或类似参数,看它有没有打印"loaded prompt from ..."。如果没有,说明是启动参数写错了,不是内容问题。
  2. 确认传入方式匹配。有的 Agent 的<管道读的是"用户输入流",而不是"初始系统上下文",两者语义完全不同。前者相当于用户说了这段话,后者相当于系统设定。我一开始就搞混了,把接力内容当成了"用户第一句话",导致 Agent 把它当成新需求而不是历史。
  3. 确认没有长度截断。有的 Agent 对初始 prompt 有长度上限,超了会静默截断。把 prompt 文件行数打印出来,和实际生效的对比。

最后定位到是第 2 条:管道方式不对,改成用专门的--context-file参数后正常。

5.2 症状二:工具调用历史丢失导致重复劳动

接力后新 Agent 又把已经读过的文件重新读了一遍,还把已经改好的代码又改了一次。根因是转换时我只保留了content,把tool_calls字段丢了。修复方法是在render_for_target里显式把工具调用渲染成文本(就是 4.4 里那段if t.tool_name的逻辑)。

这里有个经验:工具调用历史哪怕只保留"调用了什么工具、操作了什么文件"这个粒度,也比完全丢失强得多。新 Agent 看到"之前读过 src/utils.ts",就不会再读一遍。

5.3 症状三:中文内容乱码

这个坑比较低级但很常见。会话文件是 UTF-8,但脚本在某些环境下默认用了系统编码打开,导致中文变问号。解决办法是所有open()都显式指定encoding="utf-8",包括读和写。我在 4.3 的代码里已经加上了,你照抄就行。

5.4 一张排查对照表

把上面这些整理成表,方便你遇到问题时快速定位:

症状最可能的原因快速验证方法
上下文完全没生效传入方式错误(管道 vs 参数)加 verbose 看是否加载
重复读文件/改代码工具调用历史丢失检查渲染结果里有无工具信息
中文乱码编码未指定检查 open 是否带 encoding
找不到文件cwd 不对打印源会话 cwd 对比当前
内容被截断超出初始 prompt 上限对比文件行数与实际生效

6. 让接力更稳的几个进阶技巧

6.1 给接力内容加"防幻觉锚点"

新 Agent 拿到接力上下文后,有时会"脑补"一些原会话里没有的结论。我的做法是在 prompt 末尾加一段明确的边界声明:

以上内容来自另一个工具的会话记录,可能存在不完整。如果你发现信息矛盾或缺失,请先向用户确认,不要自行假设。

这句话实测能显著降低新 Agent 自作主张的概率。原理很简单:它把"信息可能不全"这个事实显式告诉了模型,模型在不确定时就更倾向于提问而不是编造。

6.2 用文件哈希做"改动感知"

接力时如果原会话改过文件,新 Agent 需要知道"哪些文件被改过"。一个轻量做法是:在采集阶段记录被工具操作过的文件路径,接力时对这些文件算一个哈希,写进 prompt。新 Agent 接手后可以自己再算一次哈希对比,就知道文件有没有在接力间隙被外部改动。这个技巧在多人和多 Agent 混用的项目里特别有用。

6.3 双向接力的会话 ID 映射

如果你经常在 A、B 两个 Agent 之间来回切,建议维护一张映射表,记录"A 的会话 X 对应 B 的会话 Y"。这样下次从 B 切回 A 时,可以直接续接 A 原来的会话,而不是又开一个新的。映射表用一个简单的 JSON 文件存就行:

{ "claude:abc123": "codex:def456", "codex:def456": "claude:abc123" }

维护这张表的成本很低,但能让你在多个 Agent 之间形成真正的"会话网络",而不是每次接力都产生一个孤立的新会话。

6.4 什么时候不该接力

最后说个反向经验:不是所有切换都值得接力。如果任务已经基本完成,或者新 Agent 要做的事情和原会话关系不大(比如原会话在调 bug,新任务是从零写一个新模块),那接力反而是负担——你花在转换和压缩上的时间,可能比新 Agent 重新理解还多。我的判断标准是:如果原会话里积累的上下文超过 5 轮有效对话,且新任务与之强相关,才值得接力。否则,直接开新会话更干净。

这套流程我在自己的日常开发里跑了几个月,从最初的手动复制粘贴,到现在基本一条命令完成切换,中间踩的坑基本都写在上面的排查链路里了。真正让我省心的不是脚本本身有多精巧,而是把"会话存在哪、格式长什么样、cwd 怎么处理"这几件事彻底搞明白了——搞明白之后,无论换哪个 Agent,接力的思路都是通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询