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_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,登录后新建一个 Key,复制保存好,后面配置里要用。注意 Key 只在创建时完整显示一次,丢了就得重新生成。
第二步,确认你要用的模型 ID。不同任务对模型能力要求不一样,长会话 Agent 建议选上下文窗口较大的模型。可以在模型对话页面先试跑几轮,确认响应正常再写进配置:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。如果你打算长期跑编码类 Agent,Coding Plan 会更划算,具体可以看 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
第三步,准备 PostgreSQL。本地用 Docker 起一个最省事:
docker run -d \ --name agent-pg \ -e POSTGRES_PASSWORD=agentpass \ -e POSTGRES_DB=agentdb \ -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,并交给 HarnessAgent:
@Bean 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 版本是 17,SpringBoot 版本是 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: total=7 msgs / <token数> tokens, cutoff=5, keeping=2 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_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 重新生成一个。
第二类,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_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 里用同样的模型 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_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的配置示例。
第五类,压缩不触发。日志里一直看不到 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 把完整结果转存到文件。当前代码没有单独配置 ToolResultEvictionConfig,HarnessAgent 会使用默认规则,单条工具结果超过 8 万字符时才转存。
Web 接口使用 streamEvents() 输出 SSE。正常情况下,每次模型推理前都会先检查压缩条件,达到阈值就主动压缩。但如果阈值设得太高,直到模型已经因为上下文超限而拒绝请求,当前流式调用不会自动压缩后重试。因此 SSE 接口要提前留出余量,不要把压缩时机卡在模型上限附近。
几种信息各回各的位置,Agent 才不会把所有状态都压在一段越来越长的聊天记录上。持久化保证重启后能找回任务,压缩保证找回的任务不会把上下文撑爆,两者配合,长会话才真正跑得稳。