之前不少团队在做 AI Agent 落地时,都会遇到同一个问题:模型明明记住了上一轮对话,但换一个会话、重启一次服务,或者任务稍微长一点,上下文就开始“漂移”。大家习惯性把对话历史一股脑塞给模型,以为这就是“记忆”,结果 token 越塞越多,回答质量反而下降。Google 最近提出的 SKILL.state 方向,就是把这个问题重新拆了一遍:与其无限保留对话历史,不如在关键节点上维护一份显式状态。这篇文章会结合 SKILL.state 的研究思路,讲清楚显式状态与对话历史的区别,并给出一套可直接参考的代码级实现方案,帮助你在实际开发中理解“如何保存对话上下文”这件事。
1. SKILL.state 是什么:解决什么问题
1.1 从一组对话记录说起
先看一个最常见的场景。用户让 Agent “帮我把项目里的 Java 版本从 8 升级到 17”,Agent 先扫描了项目结构,然后修改了 pom.xml,接着处理了几个依赖冲突,最后跑测试并给出报告。
如果把这个过程完整记录下来,你会得到一份非常长的对话历史:
用户:帮我升级项目 Java 版本 Agent:好的,我先看一下项目结构 用户:项目在 /data/project Agent:扫描到 pom.xml,当前版本是 1.8 用户:改用 Java 17 Agent:正在修改 pom.xml ...这段记录里真正有长期价值的,并不是每一句话,而是以下几个关键信息:
- 目标项目路径是 /data/project;
- 构建工具是 Maven;
- 当前 Java 目标版本是 17;
- 需要修改的文件是 pom.xml;
- 已经完成的步骤有依赖冲突处理;
- 下一步要执行测试命令。
这些信息合在一起,就是 Agent 在执行这个任务时的“状态”。对话历史只是状态的一种外在表现形式,它把真正有用的变量和大量冗余文本混在一起。
1.2 对话历史作为“隐式状态”的瓶颈
大多数 AI Agent 框架,默认用“消息列表”来保存上下文。这种方案在短对话里没有问题,但在长任务、多轮工具调用、跨会话恢复场景里,会暴露三个明显瓶颈。
第一是 token 膨胀。每一轮工具调用返回的 JSON、代码片段、日志都可能很长,模型每次请求都要重新读一遍完整的历史。任务越复杂,历史越长,推理成本和延迟都会快速上升。
第二是信息噪声。对话历史里包含大量“过程性描述”,比如“我正在读取文件”“这一步执行成功”。这些内容对于理解当前任务并不是每次都有用,但模型必须处理它们,这会稀释真正重要的状态信息。
第三是恢复困难。假设进程崩溃了,或者用户想从上一个断点继续任务,只保存对话历史往往不够。模型需要从几百条消息里重新推断“现在做到哪一步了”,推断错了,后续步骤就会出错。
换句话说,对话历史是一种隐式状态。它把状态“藏”在文本里,让模型自己理解和还原。SKILL.state 的研究思路,正是要把这份状态显式地抽出来,单独维护。
1.3 SKILL.state 的核心主张:显式状态替代隐式历史
SKILL.state 可以理解为“面向技能执行的状态管理设计”。它的核心主张是:Agent 在执行任务时,不应该只保存“聊了什么”,而应该保存“任务做到哪一步、当前掌握了哪些事实、下一步该做什么”。
从工程视角看,显式状态有点类似于传统后端开发中的“领域模型”。后端系统不会把用户的所有请求日志当作业务数据,而是会把核心信息更新到数据库对应字段里。Agent 的长期上下文,也应该遵循类似的思路。
和对话历史的对比,可以用下面这张表来快速理解:
| 维度 | 对话历史(隐式状态) | SKILL.state(显式状态) |
|---|---|---|
| 表达形式 | 自然语言消息列表 | 结构化字段、对象、键值对 |
| 信息密度 | 低,包含大量过程性内容 | 高,只保留关键变量 |
| token 消耗 | 随时间线性增长 | 基本保持稳定 |
| 跨会话恢复 | 需要模型重新理解 | 直接加载并继续执行 |
| 更新方式 | 追加一条新消息 | 增量修改状态字段 |
| 出错风险 | 长文本中信息易丢失 | 字段明确,可校验、可追踪 |
这种设计的好处是:模型不需要在每次请求时“重新读一遍小说”,只需要读一份结构清晰的“项目进度表”。
2. 工作原理拆解:从隐式上下文到结构化状态
2.1 状态、动作与技能的分工
要理解 SKILL.state,可以先把它放到一个大的 AI Agent 体系里看。一个完整技能执行流程,通常包含三个层次:
- 技能(Skill):一类可复用能力的定义,例如“升级 Java 版本”“部署应用到服务器”。
- 动作(Action):技能执行过程中的具体步骤,例如“读取 pom.xml”“执行 mvn test”。
- 状态(State):记录当前执行环境和已经发生事实的结构化数据。
状态处于整个流程的中心。动作执行前后,Agent 都要更新状态;每一次大模型决策时,都会先读取状态,再决定下一步动作。
这样的设计,也方便 Agent 在不同步骤之间共享信息。比如第一个动作读取了项目路径,后面所有动作都可以从状态里拿到这个值,不需要每次都回到对话历史里搜索。
2.2 显式状态的持久化方式
显式状态不能只存在于程序内存里,否则进程一结束就丢失了。研究思路里强调的持久化,通常指的是把状态序列化成一种可保存、可加载、可共享的格式。
常见的表现形态有:
- JSON 文件:适合单机工具,可直接人工检查和修改;
- 数据库记录:适合服务化 Agent,支持多端共享和并发控制;
- 对象存储:适合包含大文件的场景,状态中只保存文件 URL;
- 版本化目录:适合复杂任务,每一步操作后都生成新的状态快照。
在下面的实战代码中,我会用 JSON 文件作为持久化载体,因为它最直观,适合理解思路。真正落到生产环境,再根据规模替换成数据库或对象存储即可。
2.3 与 RAG、向量记忆机制的区别
很多读者会问:SKILL.state 和目前常见的“向量记忆”“RAG 知识库”有什么区别?
RAG(检索增强生成)主要负责从外部知识库中检索信息,解决的是“模型不知道某件事”的问题。比如 Agent 要去查询公司内部的部署规范,会先向量化这些规范文档,用户提问时先检索相关内容,再丢给模型。
向量记忆解决的是“相似的历史怎么找回”的问题。例如用户说“还是按上次那个风格改”,系统会把这句话转成向量,到历史记录里查找最相似的记录。
而 SKILL.state 解决的是“当前任务进行到哪一步”的问题。它不关心历史上说过多少句话,也不关心外部文档有多长,它只关心执行流程中必须持续跟踪的事实数据。两者可以同时存在:用 RAG 补充领域知识,用显式状态维护任务进度。
3. 映射到工程场景:Claude Code / AI Agent 里的状态保存
近期“claude code 怎么保存对话历史”这类问题热度很高,本质上也是开发者在为“对话状态丢失”感到困扰。我们需要把 SKILL.state 的思路落到这些实际 Agent 场景中,才能知道它到底怎么用。
3.1 为什么“保存对话历史”不等于“恢复上下文”
很多 AI 编程工具都提供了“导出对话记录”功能,把命令行里的所有消息保存成 Markdown 或 JSON。但导出对话历史,只是把聊天记录落盘,并没有把任务状态恢复出来。
举例来说,假设之前用 Claude Code 完成了一个“给项目添加 Redis 缓存”的任务。导出文件里会包含:
- 用户输入的若干条指令;
- Agent 读取过的文件路径;
- 执行过哪些重构命令;
- 最后输出的一段总结。
如果你把这个文件重新交给一个新的 Agent 会话,让模型“根据这个对话继续”,它大概率会从零开始重新理解任务。真正高效率的做法,是直接告诉新会话:
当前项目状态:已添加 spring-boot-starter-data-redis 依赖 已完成:RedisConfig 配置类 已完成:UserService 缓存注解 待办:处理缓存穿透问题 目标:保证查询性能优化后不影响一致性这一份描述,才是真正可恢复的显式状态。它会比几百条对话消息更精炼、更准确。
3.2 什么才算“好的显式状态”
判断一份状态设计得好不好,可以从四个角度评估。
第一,最小化。状态里只放任务执行必需的变量,不把无关的历史细节都塞进去。例如“刚才用户在第 5 行代码里打了感叹号”这种信息,如果没有后续影响,就不该出现在状态里。
第二,可解释。每个状态字段都应该能被业务人员看懂。target_java_version: "17"比history[3].content.substring(0, 20)更容易理解。
第三,可校验。状态里的字段应该有类型和取值范围约束,不能只是自由文本。否则 Agent 从状态里读出的值可能本身就是脏的。
第四,可回溯。应该能记录“状态什么时候被谁更新成了什么”,而不是只保存一个当前值。这样即使任务执行失败,也可以回到上一个可用状态。
3.3 状态保存的一个场景示范
假设你在用 AI 编程工具做一次“JDK 8 升级到 JDK 17”的改造,过程可能跨越好几天,中途模型需要反复重启、切换分支、甚至在另一台电脑上继续。如果你用的是对话历史,所有关键事实都散落在上千行终端日志中;如果你用 SKILL.state 的思路维护了一个状态文件,文件内容可能是这样的:
{ "task": "java_upgrade_17", "updated_at": "2025-06-10T18:30:00+08:00", "variables": { "project_path": "/data/my-service", "maven_module": ["api", "core", "infra"], "source_level": "8", "target_level": "17" }, "pending": ["处理 MapStruct 与 Java 17 的兼容问题"], "done": ["修改父 pom 的 java.version 属性", "排除重复依赖 junit"] }这份状态文件可以跟随 Git 提交,也可以放入对象存储。下一次继续任务时,只需要把这份文件中的pending和variables交给模型,它就能快速接上进度。
4. 代码实战:实现 SKILL.state 思路的 Agent 会话管理器
为了让上面的概念落地,这一节用一个 Python 示例演示如何实现一个带显式状态的 Agent 会话管理器。这个示例不需要连接任何大模型 API,核心是展示状态如何定义、如何更新、如何恢复。
4.1 使用场景设定
假设要设计一个“代码迁移助手”,它的任务是帮助用户升级项目中的 Java 版本。传统方案会保存一个 messages.json,把我们和模型的全部对话存下来。现在,我们只维护一个结构化状态对象。
先定义状态的数据结构。为了让示例简单,我直接使用 Python 字典和 dataclass 来管理,并用 JSON 文件持久化。
# 文件路径:agent_state/state.py from dataclasses import dataclass, field, asdict from typing import Dict, List from datetime import datetime import json @dataclass class TaskState: task_name: str updated_at: str = "" variables: Dict[str, object] = field(default_factory=dict) done: List[str] = field(default_factory=list) pending: List[str] = field(default_factory=list) def __post_init__(self): if not self.updated_at: self.updated_at = datetime.now().isoformat(timespec="seconds") def mark_done(self, step: str): """把某个待办移入已完成列表,并更新时间戳。""" if step in self.pending: self.pending.remove(step) if step not in self.done: self.done.append(step) self.updated_at = datetime.now().isoformat(timespec="seconds") def add_pending(self, step: str): """新增一个待办事项,避免重复添加。""" if step not in self.pending and step not in self.done: self.pending.append(step) self.updated_at = datetime.now().isoformat(timespec="seconds")这段代码定义了三个核心能力:
variables:保存项目环境中的关键变量;done:记录已经完成的操作;pending:记录待执行的步骤。
这里的mark_done方法,模拟了 Agent 每完成一步动作后的“状态更新”行为。
4.2 持久化:保存与恢复状态
有了状态结构之后,下一步是提供两个工具函数:一个负责把状态写到硬盘,另一个负责从硬盘读取状态。这是整个显式状态管理最核心的基础设施。
# 文件路径:agent_state/store.py import json import os from pathlib import Path from typing import Optional from .state import TaskState def save_task_state(state: TaskState, path: str) -> str: """将状态对象序列化为 JSON 并写入文件。 为了安全,先写入临时文件,再替换旧文件, 避免进程中断时留下半截坏数据。 """ save_path = Path(path) save_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = save_path.with_suffix(suffix=".tmp") payload = json.dumps( { "task_name": state.task_name, "updated_at": state.updated_at, "variables": state.variables, "done": state.done, "pending": state.pending, }, ensure_ascii=False, indent=2, ) tmp_path.write_text(payload, encoding="utf-8") os.replace(tmp_path, save_path) return f"state saved -> {save_path}" def load_task_state(path: str) -> Optional[TaskState]: """从 JSON 文件恢复状态,如果文件不存在则返回 None。""" load_path = Path(path) if not load_path.exists(): return None with open(load_path, "r", encoding="utf-8") as f: data = json.load(f) return TaskState( task_name=data.get("task_name", "unknown_task"), updated_at=data.get("updated_at", ""), variables=data.get("variables", {}), done=data.get("done", []), pending=data.get("pending", []), )存储部分有两个值得注意的细节。
一个是写临时文件后os.replace,这个操作在大部分操作系统上是原子的,可以防止进程中途崩溃导致 JSON 文件损坏。
另一个是ensure_ascii=False,这样保存的中文内容在文件里是可读的。生产环境排查问题时,可以直接打开状态文件人工核对。
4.3 把状态转换成模型可读的 Prompt
显式状态的价值,在于能被大模型直接使用。所以还需要一个方法,把状态对象压缩成一段结构化的系统提示词,而不需要用对话历史填充。
# 文件路径:agent_state/prompt_builder.py from .state import TaskState def build_state_prompt(state: TaskState) -> str: """将当前状态渲染为模型输入的一段 system prompt。 这段文本的核心思想:只保留必要信息,减少历史噪声。 """ variables_block = "\n".join( f"- {key}: {value}" for key, value in state.variables.items() ) done_block = "\n".join(f"- {item}" for item in state.done) or "- 暂无" pending_block = "\n".join(f"- {item}" for item in state.pending) or "- 暂无" return f"""当前任务:{state.task_name} 状态更新时间:{state.updated_at} 关键变量: {variables_block} 已完成步骤: {done_block} 待执行步骤: {pending_block} 请根据上述状态继续执行,不要重复已完成步骤。 """对比一下,传统对话历史的 Prompt 可能是几万字的消息列表;而这里生成的 Prompt,是一份结构清晰的“项目卡片”。模型读完这份卡片后,能准确知道当前进度,不会把已经完成的事情再做一遍。
4.4 完整流程演示
下面把整个流程串起来,演示“开始任务 → 更新状态 → 中断 → 恢复任务”的完整循环。
# 文件路径:examples/demo_skill_state.py import sys from pathlib import Path # 将项目根目录加入模块搜索路径,方便直接从命令行运行 sys.path.append(str(Path(__file__).resolve().parents[1])) from agent_state.state import TaskState from agent_state.store import save_task_state, load_task_state from agent_state.prompt_builder import build_state_prompt STATE_FILE = "examples/tmp_task_state.json" def main(): # 第一次新任务:初始化状态 state = load_task_state(STATE_FILE) if state is None: print(">>> 没有历史状态,开始新任务初始化") state = TaskState( task_name="java_version_upgrade", variables={ "project_path": "/data/my-service", "build_tool": "maven", "source_version": "8", "target_version": "17", }, done=[], pending=[ "扫描项目模块结构", "修改父 pom 的 java.version 属性", "检查依赖冲突", "运行单元测试", ], ) else: print(">>> 检测到历史状态,直接恢复任务进度") # 模拟 Agent 第二步动作:扫描模块结构完成 state.mark_done("扫描项目模块结构") # 模拟新增一个后续待办,例如模型发现 MapStruct 版本过旧 state.add_pending("升级 MapStruct 到兼容 Java 17 的版本") # 更新 state.variables 中的关键信息 state.variables["module_count"] = 3 # 保存状态 save_task_state(state, STATE_FILE) # 输出 Prompt print("\n===== 模型可读的显式状态提示词 =====\n") print(build_state_prompt(state)) if __name__ == "__main__": main()运行这个脚本前,先确保目录结构如下:
skill-state-demo/ ├── agent_state/ │ ├── __init__.py │ ├── state.py │ ├── store.py │ └── prompt_builder.py └── examples/ ├── demo_skill_state.py └── tmp_task_state.json使用命令行执行:
cd skill-state-demo python examples/demo_skill_state.py第一次执行的输出大致如下:
>>> 没有历史状态,开始新任务初始化 >>> 状态已保存 ===== 模型可读的显式状态提示词 ===== 当前任务:java_version_upgrade 状态更新时间:2025-06-11T10:15:32+08:00 关键变量: - project_path: /data/my-service - build_tool: maven - source_version: 8 - target_version: 17 - module_count: 3 已完成步骤: - 扫描项目模块结构 待执行步骤: - 修改父 pom 的 java.version 属性 - 检查依赖冲突 - 运行单元测试 - 升级 MapStruct 到兼容 Java 17 的版本 请根据上述状态继续执行,不要重复已完成步骤。再次运行同一段脚本,输出会变为:
>>> 检测到历史状态,直接恢复任务进度状态文件被加载后,任务继续推进,不会因为进程重启而丢失。
4.5 和“保存对话历史”方案对比
作为收尾,我们做一次小规模对比,从三个维度看看显式状态方案的优势。
| 对比项目 | 保存对话历史 | SKILL.state 显式状态 |
|---|---|---|
| 磁盘占用 | 持续增长,可能达到 MB 级 | 稳定在 KB 级 |
| 恢复后能否直接执行 | 模型需要重读并推断上下文 | 字段明确,可以直接继续 |
| 是否能回答“我现在在哪一步” | 需要人工翻阅 | 打开 JSON 即可看到 pending 列表 |
这里并不是说对话历史完全没有作用。对话历史仍然适合做审计追踪、用户偏好分析和异常定位;但在“让 Agent 接着干活”这件事上,它不应该替代显式状态的位置。
5. 常见问题与排查思路
5.1 状态文件读取报错
错误现象:
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes常见原因:状态文件在写入过程中被中断,残留了不完整 JSON;或者手写文件时少了逗号、少了引号。
解决思路:
# 先备份损坏文件 cp task_state.json task_state.json.bak # 用 python 工具检测语法位置 python -m json.tool task_state.json如果损坏文件无法恢复,可以回到 Git 历史或对象存储的上一个版本。这也是 4.2 节使用“先写临时文件再替换”的原因,就是为了减少这类风险。
5.2 任务恢复后出现了重复操作
错误现象:Agent 恢复后,把之前已经改过的 pom.xml 又改了一遍。
常见原因:状态中done列表不完整,模型不知道某些步骤已经完成;或者不同的动作描述实际指向同一个操作。
解决思路:
- 在
mark_done时使用精确的操作描述,不要写“处理依赖问题”这种模糊文本; - 把关键文件路径放入
variables,让模型判断是否已处理; - 每次更新状态后,检查
done和pending是否有重复交集。
5.3 状态字段不断膨胀
错误现象:状态里的pending越加越多,从 5 项涨到 50 项,失去可读性。
常见原因:代码把模型的每一步输出都当成新 pending,而不是把多个子步骤合并成一个待办。
排查思路:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| pending 越来越多 | 每个小动作都单独记录 | 使用层级式待办,例如“模块 A 升级”下挂多个子项 |
| done 有大量相似项 | 步骤命名不够统一 | 定义动作规范词表,例如“修改配置、运行测试、提交代码” |
| 状态恢复后行为不一致 | 状态字段类型不固定 | 增加 schema 校验,确保 value 类型稳定 |
| 多端同时修改状态 | 没有版本控制 | 引入 version 字段,保存时判断是否为最新值 |
5.4 状态文件被并发写入
在多进程或多线程场景下,两个 Agent 同时更新一个状态文件,会导致更新丢失。
解决思路是加入版本号,类似乐观锁:
# 在 TaskState 中增加 version 字段 version: int = 1 def mark_done(self, step: str): if self.version == 0: raise ValueError("状态版本异常,请重新加载") # ... 更新逻辑 self.version += 1保存时带上版本号;如果文件中的版本号比内存中的新,说明有其他端更新过状态,应该先重新加载再合并。
6. 最佳实践与工程建议
6.1 状态字段要有 schema 校验
显式状态本质上是一份有约束的数据,不能像对话历史一样“想到哪写到哪”。建议团队在状态模块中引入 schema 定义,例如用 JSON Schema 约束必填字段和类型。这样即使 Agent 给自己“编造”了一个错误字段,保存阶段也能及时发现,而不是带着脏数据继续往后跑。
6.2 区分长期状态与临时状态
不是所有信息都值得放进 SKILL.state。建议按以下规则区分:
- 长期状态:任务目标、项目路径、关键配置值、已完成步骤、当前待办。
- 临时变量:某次函数调用的中间结果、模型生成的临时总结、用户可以随时再说一遍的话。
长期状态写入持久化文件;临时变量只保存在当前进程里。避免把 Agent 执行过程中的每一条输出都变成永久状态。
6.3 每次执行动作后都更新状态
很多 Agent 框架只有在任务结束时才保存对话记录,中间不保存任何东西。这会导致一个很尴尬的情况:任务执行 20 分钟后崩溃,所有进度全部丢失。
更合理的做法是:每完成一个原子动作,就更新一次状态。这里的“原子动作”可以是:
- 成功修改一个文件;
- 成功执行一条命令;
- 成功调用一次检索 API;
- 成功完成一次用户确认。
更新频率提高会增加少量 I/O 成本,但换来的是高韧性的任务恢复能力,在生产环境中非常划算。
6.4 让大模型来维护状态结构
有了结构化状态和 Prompt 之后,可以进一步让模型自己维护状态。例如,每轮交互结束时,系统给模型发一个工具调用请求update_task_state,让模型从对话里抽取新的变量,并调用本地函数来更新状态对象。
示例伪代码如下:
def update_task_state(task_state, user_message, model_reply): """ 让模型分析用户输入和模型输出,返回结构化的状态增量。 生产环境建议使用对应的 function calling 能力,这里只描述接口约定。 """ extracted = llm_extract( content=user_message + model_reply, schema={ "done": ["string"], "pending": ["string"], "variables": {"type": "object"}, } ) for step in extracted.get("done", []): task_state.mark_done(step) for step in extracted.get("pending", []): task_state.add_pending(step) if extracted.get("variables"): task_state.variables.update(extracted["variables"])这个思路本质上是把“从对话历史维护状态”的工作自动化,而不是让开发者每次手工定义一个mark_done。
6.5 状态文件应该进入版本管理
对于代码项目类 Agent 任务,强烈建议把状态文件提交到 Git 仓库。这样:
- 可以查看每个时间点的任务状态变化;
- 任务出错时可以通过
git diff精确定位是哪一步改变了关键变量; - 可以支持“回滚到上一个版本”的任务恢复策略。
当然,状态文件里如果包含密钥、内部 IP 等敏感信息,就不要直接进仓库,应使用环境变量或密钥管理服务。
6.6 注意保留对话历史用于审计
虽然状态替代了大部分历史,但对话历史仍有价值。发生问题时,我们需要回溯“用户当时到底说了什么,Agent 才会做出错误修改”。状态解决的是执行问题,历史解决的是责任归属和过程审计问题。两者不是二选一的关系。
7. 总结与后续学习方向
围绕 SKILL.state 的讨论,核心是对“上下文的本质”做了一次重新审视:对话历史不是唯一值得保存的东西,甚至不是最高效的记忆载体。真正能让 Agent 稳定工作、跨会话恢复的,是一份不断更新的显式状态。在实际开发中,你可以从今天这篇代码示例出发,把 Agent 的消息记录与状态对象拆开,先给状态加上 JSON 持久化,再逐步把变量更新、待办管理、版本控制纳入体系。
接下去可以继续学习的方向包括:
- 函数调用(function calling)与工具调用的状态同步机制;
- 更复杂的任务编排框架中,状态机的建模方式;
- 使用向量数据库保存长期用户偏好,与短期执行状态互补;
- Agent 任务的可观测性设计:如何记录状态变更事件,方便后续追踪。
核心要记住一个原则:不要往大模型的上下文里堆一切内容,要为每类信息找到精准的容器。任务进度放进显式状态,领域知识交给检索系统,过程记录保留在日志中,这样模型才能轻装上阵,任务也更容易稳定落地。