智能对话压缩技术:用 PostgreSQL 持久化 AgentState 的上下文管理革新
1. 长会话 Agent 的上下文膨胀与状态丢失到底卡在哪智能对话压缩技术要解决的核心问题是长会话 Agent 在持续运行几小时甚至几天后上下文窗口被历史消息撑满、应用重启后任务状态又找不回来的双重困境。它适合正在做多轮 Agent 应用、需要持久化会话状态并控制 token 成本的开发者。持久化解决的是重启后还能不能接着聊上下文管理解决的是接着聊的时候历史会不会无限增长这两件事必须一起做。我见过不少团队先上了 PostgreSQL 存 AgentState重启恢复没问题但跑上几十轮之后调用越来越慢token 账单肉眼可见地涨最后直接撞上模型上下文窗口上限报错。也有人只做了压缩当前进程里上下文确实变短了可服务一重启之前压缩出来的摘要和任务进度全丢了用户得从头再说一遍需求。这两个问题的根源在于AgentState 里存的不只是聊天记录而是任务继续执行所需的全部信息——用户目标、已确认事实、工具调用链、待办事项。如果原样全量发送给模型历史只会越滚越大如果只压缩不持久化压缩结果活不过一次进程重启。正确的链路应该是这样从 PostgreSQL 恢复 AgentState加入本轮用户消息模型推理前检查压缩条件较早消息变成摘要、最近消息保留原文模型继续处理本轮请求更新后的 AgentState 再写回 PostgreSQL。持久化和压缩操作的是同一份会话状态只是职责不同——前者负责保存和恢复后者负责缩短历史消息。本文会给出 AgentState 表结构、压缩触发阈值配置、可复制的 Java 与 YAML 片段并演示重启后上下文恢复的验证动作。如果你正在用 AgentScope Java 搭 Web Agent这套改动能直接套用代码变化集中在三个位置不用动 Controller、Service 和接口地址。2. 前置准备TaoToken 接入与 PostgreSQL 环境就位在动手改压缩配置之前先把模型调用链路和数据库底座准备好。模型侧我用的是 TaoToken 的 API 接入它兼容 OpenAI 风格的请求格式拿到 Key 之后填进配置就能用不需要改现有代码结构。第一步去控制台创建 API Key。打开 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 登录后新建一个 Key复制保存好后面配置里要用。注意 Key 只在创建时完整显示一次丢了就得重新生成。第二步确认你要用的模型 ID。不同任务对模型能力要求不一样长会话 Agent 建议选上下文窗口较大的模型。可以在模型对话页面先试跑几轮确认响应正常再写进配置https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 。如果你打算长期跑编码类 AgentCoding Plan 会更划算具体可以看 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。第三步准备 PostgreSQL。本地用 Docker 起一个最省事docker run -d \ --name agent-pg \ -e POSTGRES_PASSWORDagentpass \ -e POSTGRES_DBagentdb \ -p 5432:5432 \ postgres:16起来之后建一张 AgentState 表。这张表的核心字段是会话标识和状态内容状态内容用 JSONB 存方便后续扩展CREATE TABLE IF NOT EXISTS agent_state ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(128) NOT NULL, session_id VARCHAR(128) NOT NULL, state_json JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uk_user_session UNIQUE (user_id, session_id) ); CREATE INDEX idx_agent_state_updated ON agent_state (updated_at DESC);user_id 和 session_id 组成唯一约束保证同一个会话只有一份状态。state_json 里存的就是 AgentState 序列化后的内容包括消息列表、上下文摘要等。updated_at 用于排查和清理过期会话。第四步把模型配置写进 application.yml。这里同时把 TaoToken 的 Base URL、Key 和 Model ID 三件套配齐app: dev-agent: model: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model-id: your-model-id datasource: url: jdbc:postgresql://localhost:5432/agentdb username: postgres password: agentpassapi-key 用环境变量注入别硬编码进仓库。Base URL 用 https://taotoken.net/api 这个地址不带任何多余路径。Model ID 填你在模型对话里验证过的那个。到这里模型调用和数据库底座就都就位了。接下来进入压缩配置的改造这才是控制上下文长度的关键。3. 可复制配置压缩阈值、表结构与三处代码改动接入压缩能力代码变化集中在三个位置application.yml 增加压缩参数和摘要提示词DevAgentProperties 接收新增配置AgentScopeConfiguration 创建 CompactionConfig 并交给 HarnessAgent。工具注册、权限规则、Workspace 和 AgentStateStore 全部沿用现有实现不用新增 Maven 依赖。先看 application.yml 里 app.dev-agent 下新增的 compaction 段app: dev-agent: compaction: trigger-messages: 6 keep-messages: 2 summary-prompt: | 请把下面的会话整理成一份供后续任务继续使用的上下文摘要。 只保留用户目标、已经确认的事实、尚未完成的事项和明确编号。 不要补充会话中没有出现的信息。 使用下面的结构 ## 当前目标 ## 已确认信息 ## 待处理事项 会话内容 {messages}trigger-messages 的消息不是六次提问而是参与推理的非 System 消息。一次普通问答通常包含一条 User 消息和一条 Assistant 消息如果模型调用工具工具调用会放在 Assistant 消息里执行结果还会追加 Tool 消息。keep-messages 表示普通情况下保留最近两条消息原文如果切分位置落在工具调用和工具结果之间框架会调整边界实际保留条数可能变化。这里的阈值只是为了快速触发效果。正式环境更适合结合模型上下文窗口、工具结果大小和任务平均轮数来定。阈值太低并不会更省因为生成摘要本身也要调用一次模型频繁触发反而增加额外请求。接着改 DevAgentProperties增加 Compaction 字段public record DevAgentProperties( NotBlank String name, NotBlank String systemPrompt, NotBlank String projectRoot, NotBlank String workspaceRoot, Valid Compaction compaction, Valid Model model) { public record Compaction( Min(2) int triggerMessages, Min(1) int keepMessages, NotBlank String summaryPrompt) { } }Valid 让嵌套配置参与校验Min 给消息阈值和保留条数设置下限。应用启动时就能发现明显错误不必等到会话压缩时再报错。最后在 AgentScopeConfiguration 里创建 CompactionConfig Bean并交给 HarnessAgentBean CompactionConfig compactionConfig(DevAgentProperties properties) { DevAgentProperties.Compaction config properties.compaction(); return CompactionConfig.builder() .triggerMessages(config.triggerMessages()) .keepMessages(config.keepMessages()) .keepTokens(0) .summaryPrompt(config.summaryPrompt()) .flushBeforeCompact(false) .offloadBeforeCompact(false) .build(); }原来明确关闭压缩的.disableCompaction()替换成.compaction(compactionConfig)HarnessAgent 的 Bean 方法多接收一个 CompactionConfig 参数即可。这里有三个容易混淆的配置。keepTokens(0) 表示按 keepMessages 保留最近消息如果设置为大于 0 的值框架会按固定 token 预算保留尾部设置为 -1 时才是根据模型上下文窗口动态计算。flushBeforeCompact(false) 关闭压缩前的长期记忆提取当前示例只验证会话摘要不把旧对话另外写入 Memory。offloadBeforeCompact(false) 关闭压缩前的原始消息归档如果系统有审计或历史检索要求可以开启它把压缩前的完整消息保存到 Workspace 下的会话 JSONL 文件。4. 验证请求curl 触发压缩并确认重启后恢复配置改完用同一个 userId 和 sessionId 发四次请求来触发压缩。第一次给出任务范围curl -sN -X POST http://localhost:8080/dev-agent/ask \ -H Content-Type: application/json \ -d { userId: context-user-009, sessionId: context-session-009, message: 任务编号是 CTX-009。需要确认 Java 版本、SpringBoot 版本、启动类、源码目录、构建命令和测试命令。只确认收到不要调用工具。 }第二次和第三次补充已经确认的信息curl -sN -X POST http://localhost:8080/dev-agent/ask \ -H Content-Type: application/json \ -d { userId: context-user-009, sessionId: context-session-009, message: 已确认 Java 版本是 17SpringBoot 版本是 4.1.0。只确认收到不要调用工具。 } curl -sN -X POST http://localhost:8080/dev-agent/ask \ -H Content-Type: application/json \ -d { userId: context-user-009, sessionId: context-session-009, message: 已确认启动类是 AgentScopeJavaApplication源码目录是 src/main/java。只确认收到不要调用工具。 }前三轮每轮各产生一条 User 消息和一条 Assistant 消息共六条。压缩条件只在下一次模型推理前检查所以第四次请求加入新的 User 消息后框架看到七条消息并触发压缩curl -sN -X POST http://localhost:8080/dev-agent/ask \ -H Content-Type: application/json \ -d { userId: context-user-009, sessionId: context-session-009, message: 汇总已经确认的信息并列出还没有确认的事项。不要调用工具。 }日志会出现类似内容Compaction triggered: total7 msgs / token数 tokens, cutoff5, keeping2 msgs Compaction complete: 7 msgs - 1 summary 2 tail 3 total第一行表示压缩开始当前共有 7 条消息前 5 条会被整理成摘要最近 2 条保留原文。第二行表示压缩完成模型接下来看到的历史不再是原来的 7 条消息而是一条摘要加最近两条原始消息。第四轮回答大致如下已确认 - Java 版本17 - SpringBoot 版本4.1.0 - 启动类AgentScopeJavaApplication - 源码目录src/main/java 待确认 - 构建命令 - 测试命令这说明较早的原文已被摘要替代但任务编号、已确认信息和待办没有丢失。日志里的 3 total 是压缩刚完成时的消息数第四轮回答生成后也会追加到上下文并随更新后的 AgentState 写入 PostgreSQL。接下来验证重启恢复。先停掉应用再重新启动然后用同一个 sessionId 发一条查询请求curl -sN -X POST http://localhost:8080/dev-agent/ask \ -H Content-Type: application/json \ -d { userId: context-user-009, sessionId: context-session-009, message: 当前任务编号是什么已经确认了哪些信息不要调用工具。 }如果恢复成功模型应该能答出 CTX-009 以及之前确认的 Java 版本、SpringBoot 版本等信息。这说明压缩后的摘要和最近消息一起被写回了 PostgreSQL重启后从库里恢复出了完整的 AgentState。你也可以直接查库确认状态确实落盘了SELECT user_id, session_id, updated_at, jsonb_array_length(state_json - context) AS context_len FROM agent_state WHERE session_id context-session-009;context_len 应该是一个较小的数字而不是原始七条消息的长度说明压缩结果已经持久化。5. 常见报错排查401、local proxy failed 与 reading choices接入过程中最容易撞上的几类报错这里逐个对照排查。第一类401 Unauthorized。报错信息通常是{error:{message:Invalid API key,type:invalid_request_error}}。原因一般是 api-key 没注入成功或者 Key 复制时带了空格。检查 application.yml 里是不是用了${TAOTOKEN_API_KEY}环境变量启动前确认echo $TAOTOKEN_API_KEY有值。如果直接写死在配置里确认没有多余引号和换行。Key 本身失效的话去 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 重新生成一个。第二类local proxy failed 或连接被拒绝。这类报错说明请求根本没发出去通常是 base-url 写错了。确认配置里是https://taotoken.net/api不要多加/v1或结尾斜杠。如果你本地有网络层工具在跑先确认它没有拦截这个域名。另外检查应用启动日志里模型客户端初始化时打印的 base URL 是不是你期望的那个。第三类reading choices 相关报错比如Cannot read field choices because response is null或reading choices。这通常意味着返回体不是标准的 chat completion 结构可能是模型 ID 填错了或者请求被网关拦截返回了 HTML 错误页。先确认 model-id 是你在模型对话里验证过能正常返回的那个。可以在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 里用同样的模型 ID 发一条消息看返回是否正常。如果那边正常、这边报错就是配置里的 model-id 和验证时用的不一致。第四类OAuth 或鉴权相关报错。如果你用的是 Claude Code 这类需要 OAuth 流程的工具报错可能是OAuth token expired或authentication failed。这类场景建议直接走 API Key 方式配置 Base URL、Key 和 Model ID 三件套即可不依赖 OAuth 刷新流程。Claude Code 的接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的配置示例。第五类压缩不触发。日志里一直看不到 Compaction triggered先确认 trigger-messages 是不是设得太大消息数没到阈值自然不会触发。再确认.disableCompaction()是不是真的替换成了.compaction(compactionConfig)如果两处都留着后者可能被覆盖。还有一种情况是 System 消息不计入阈值如果你把大量内容塞进了 systemPrompt实际参与计数的消息数会比你以为的少。第六类重启后上下文丢失。查库确认 agent_state 表里有没有对应 session_id 的记录。如果没有说明写回环节没生效检查 AgentStateStore 的 Bean 是不是正确注入到了 HarnessAgent。如果有记录但恢复出来是空的检查 state_json 里的 context 字段结构是否和恢复逻辑匹配。6. 语义一致收尾把状态放回它该在的位置长会话真正需要保住的不是每一句原话而是任务还能继续执行所需的信息。Compaction 把旧消息整理成摘要最近消息保留原文再把新的上下文写回 AgentState。PostgreSQL 负责下次把它找回来AGENTS.md 继续提供项目规则关键业务状态则留在结构化存储里。这里要分清几种信息各自该放哪。当前目标、已完成步骤、下一步适合放进 Compaction 摘要项目背景、工具规则、输出要求写进 AGENTS.md用户偏好、长期约定交给 Memory审批状态、订单号、发布批次这类不能出错的信息必须放进业务表或结构化状态压缩前的完整对话如果需要审计开启 offloadBeforeCompact 存到会话原始日志。还有一种情况容易被忽略对话没进行几轮但某个工具一次返回了几万行日志。这时问题不在历史消息太多而在单条工具结果太大。这类结果由 ToolResultEvictionMiddleware 处理阈值和预览长度通过 ToolResultEvictionConfig 配置。超过阈值后完整内容会写入 Workspace对话中只留下开头、结尾和文件位置。它和 Compaction 的区别是聊了很多轮导致历史消息越来越长用 Compaction 把旧对话整理成摘要某个工具一次返回了大量内容用 ToolResultEviction 把完整结果转存到文件。当前代码没有单独配置 ToolResultEvictionConfigHarnessAgent 会使用默认规则单条工具结果超过 8 万字符时才转存。Web 接口使用 streamEvents() 输出 SSE。正常情况下每次模型推理前都会先检查压缩条件达到阈值就主动压缩。但如果阈值设得太高直到模型已经因为上下文超限而拒绝请求当前流式调用不会自动压缩后重试。因此 SSE 接口要提前留出余量不要把压缩时机卡在模型上限附近。几种信息各回各的位置Agent 才不会把所有状态都压在一段越来越长的聊天记录上。持久化保证重启后能找回任务压缩保证找回的任务不会把上下文撑爆两者配合长会话才真正跑得稳。