从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南
本文既是本项目的完整技术文档,也是一份以真实工程为载体的 AI Agent 开发学习教程。
全文遵循「先讲理论、再结合本项目真实代码印证」的写法,所有结论均有据可依(源自项目 md 设计文档、.joycode/memory记忆沉淀,或domain/infra/client真实代码),力求面面俱到、严谨可查。
阅读对象:希望通过一个真实项目系统学习 Agent 开发(含 AgentScope 框架、DDD 分层、数据持久化、前后端交互)的工程师。
目录
- 第一篇 项目全景:它是什么、解决什么问题、总体架构长什么样
- 第二篇 知识地基:AI Agent 开发的核心概念与行业标准术语
- 第三篇 骨架:DDD 四模块分层与依赖倒置(端口—适配器)
- 第四篇 血脉:后端核心链路(SSE 流式、HITL 人工确认、命令审批、请求级幂等)
- 第五篇 根系:数据持久化(官方状态表 + 自建业务表、Cache-Aside、对话流水账、幂等 CAS)
- 第六篇 灵魂:多层记忆、Skill 渐进式披露、经验沉淀闭环
- 第七篇 触角:MCP 工具服务器(git-mcp / log-mcp)与「沙箱如何读到最新全量代码」
- 第八篇 深潜:短期状态的「重建」(StateRebuildService 回放)与两级缓存的「一致性」(CachedAgentStateStore)
- 第九篇 补白:前端四个「不得不讲」的交互细节(401 拦截 / 停止生成 / 历史回显 / Skill 选择态)
- 第十篇 收尾:优雅停机——在途请求落盘、流水账排空的有序收敛,与挂起 HITL 的三条清理路径
第一篇 项目全景
1.0 写在最前面
1.0.1 源码地址
1.0.2 参考文档
java.agentscope.io/v2/zh/docs/…
1.0.3 Agent效果演示
1.1 这是一个什么项目
这是一个基于 AgentScope 2.0.1(Java 版 Agent 框架)与 DeepSeek 大模型构建的、面向生产环境的记忆型智能体(Agent)后端系统。
它不是一个「跑通 Demo」级别的玩具,而是一套按真实高并发生产标准落地的工程:具备多用户隔离、多会话管理、流式对话、人工确认(HITL)、命令审批、请求级幂等、跨会话记忆、经验自沉淀、可复用技能(Skill)体系等完整能力,并以 DDD(领域驱动设计)四模块分层组织代码。
通俗地说:它是一个「能对话、能调工具、能记住你、能越用越聪明、且在分布式环境下不会算错账」的 AI 助手后端。
1.2 它要解决的核心问题
把一个大模型(LLM)包装成「可用的对话接口」并不难,难的是让它在真实生产环境里稳定、正确、安全地运行。本项目正是围绕以下一组真实痛点构建的:
| 痛点 | 朴素做法的问题 | 本项目的解法(后续章节详解) |
|---|---|---|
| 对话要「打字机」效果 | 一次性返回,用户等待感强 | SSE 流式逐 token 下发(第四篇) |
| Agent 要调危险工具(删文件、执行命令) | 直接执行有风险 | HITL 人工确认 + 命令审批双机制(第四篇) |
| 网络抖动导致前端重试 | 同一问题被处理两次、重复计费、重复落账 | 请求级幂等四态状态机(第四、五篇) |
| 多用户共用一套服务 | 越权访问他人会话/记忆 | userId 只从 JWT 取、绝不读请求体(第四篇) |
| 希望 Agent「记得住」 | 每次对话都是「失忆」的 | 短期状态存储 + 跨会话长期记忆 + 经验沉淀(第五、六篇) |
| 希望 Agent「越用越聪明」 | 经验散落在历史对话里无法复用 | 高发问题定时提炼为全局共享经验(第六篇) |
| 能力要能积累复用 | 提示词硬编码、无法沉淀 | Skill 技能体系 + 渐进式披露(第六篇) |
| 代码要能长期演进 | 业务与框架耦合、牵一发动全身 | DDD 分层 + 依赖倒置(第三篇) |
1.3 总体架构一览
系统按 DDD 划分为四个 Maven 模块,依赖方向经过刻意设计的「依赖倒置」(详见第三篇):
四模块职责速览:
starter:Spring Boot 启动模块(AgentApplication)、全局配置application.properties、建表脚本schema.sql(共 8 张表)。domain:领域核心。放置对话编排AgentService、一组「端口接口」(Skill/记忆/流水账/幂等/权限/会话)、技术中立的流式接口StreamSink,以及一批只读视图dto(Javarecord)。不依赖任何 Web/持久化技术。infra:技术实现。实现 domain 的端口接口,装配 DeepSeek 模型 Bean、MCP 客户端、两级状态存储、MyBatis-Plus 持久化。client:入口与装配。Web 控制器AgentController、SSE 桥接、JWT 鉴权、定时任务(经验提炼、数据治理)。
关键架构决策(记忆沉淀印证) :依赖方向是
client → domain ← infra,即 infra 依赖 domain(依赖倒置),而非传统的 domain 依赖 infra。这是本项目 DDD 重构的核心,第三篇会用pom.xml与端口接口逐一印证。
1.4 一次对话的生命周期(先建立直觉)
为了让后续章节有整体坐标,先粗略走一遍「用户发一条消息」会发生什么(细节在第四篇展开):
- 前端
POST /agent/chat/stream,带sessionId、requestId、message。 AgentController从 JWT 上下文取userId(绝不信任请求体)。- 幂等闸口:
(userId, sessionId, requestId)三元组原子判重——首见放行,重复则重放或返回 409。 - 建立永不超时的 SSE 连接,桥接成技术中立的
StreamSink,登记进「保活表」。 - 委托
AgentService驱动 AgentScope 的ReActAgent流式推理,逐 token 通过StreamSink下发。 - 若命中危险工具 → 发
ask事件挂起,等用户permission/reply后恢复续传(HITL)。 - 流结束 → 落账到
conversation_log(虚拟线程异步直写)、幂等记录置DONE。 - 高发问题会被定时任务离线提炼成「经验」,回填到全局共享记忆,供后续对话召回。
第二篇 知识地基:AI Agent 开发的核心概念与行业标准术语
本篇集中梳理理解本项目所需的 AI Agent 行业标准术语。学习本项目前,先建立这套「词汇表」,后续章节会反复用到。每个术语给出「标准定义 + 在本项目的对应物」。
2.1 LLM、Prompt 与上下文(Context)
- LLM(Large Language Model,大语言模型) :本项目使用 DeepSeek。它是无状态的——每次请求都要把「它需要知道的一切」重新喂进去。
- Prompt(提示词) :喂给模型的输入文本。分为 System Prompt(系统提示词,设定角色与规则) 与 User Prompt(用户消息) 。
- Context / Context Window(上下文 / 上下文窗口) :模型单次能「看到」的 token 总量上限。这是 Agent 工程的核心约束——上下文是稀缺资源,所有记忆、Skill、历史对话都要在有限窗口内精打细算。
- Context Engineering(上下文工程) :决定「在有限窗口里放什么、怎么放」的工程实践。本项目的 Skill 渐进式披露、记忆分层召回,本质都是上下文工程。
2.2 Agent 与 ReAct 范式
- Agent(智能体) :能自主「感知—决策—行动」的 LLM 应用。区别于单轮问答,Agent 能多轮循环、调用工具、根据结果继续决策。
- ReAct(Reasoning + Acting,推理—行动范式) :Agent 的经典工作模式——模型交替进行「推理(Thought)」与「行动(Action,即调用工具)」,用工具返回的「观察(Observation)」指导下一步。本项目的
ReActAgent(AgentScope 提供)正是这一范式的实现。 - Tool / Function Calling(工具 / 函数调用) :让模型「调用外部能力」的机制。模型输出一个结构化的「工具调用请求」,由框架执行真实函数,再把结果回喂。本项目工具包括文件读写、命令执行、Git/日志查询(经 MCP)。
- Toolkit(工具包) :一组工具的集合。本项目的关键约束:AgentScope 的
Toolkit在 Agent 构造期被final绑定,无法运行时切换(第三、四篇详述)——这直接决定了「每会话独立构建 Agent」的架构。 - Agent Loop / maxIters(迭代上限) :ReAct 循环的最大轮数,防止模型陷入「反复调工具不收敛」的死循环。本项目曾因「诊断子 Agent 单轮取值」触发死循环,最终取消子 Agent(第三篇)。
2.3 记忆(Memory)的分层
Agent 的「记忆」并非单一概念,行业通常分层理解,本项目也严格对应:
- 短期记忆 / 会话状态(Short-term / Session State) :单次会话内的对话历史与 Agent 内部状态。本项目由 AgentScope 的
AgentStateStore承载,落 MySQLagentscope_sessions表 + Redis 热缓存(第五篇)。 - 长期记忆(Long-term Memory) :跨会话、可被未来对话召回的知识。注意:AgentScope 2.0.1 的官方
LongTermMemory接口已@Deprecated(forRemoval),因此本项目的跨会话记忆是应用层自建的(经验表 + 流水账),而非依赖框架(记忆沉淀印证)。 - 经验记忆(Experience / Distilled Memory) :从大量历史对话中「提炼」出的结构化知识(根因、排查步骤 SOP)。本项目通过定时任务离线提炼,写入
__global__全局共享维度(第六篇)。 - 语义指纹(Fingerprint) :把「语义相近的问题」归一化成同一个键,用于经验的聚合与召回。本项目有一套确定的指纹算法(第六篇详述)。
2.4 人在回路(HITL)与权限
- HITL(Human-in-the-Loop,人在回路) :在 Agent 执行高风险动作前暂停,交由人类确认后再继续。这是 Agent 安全的核心机制。本项目对危险工具(如删除、命令执行)挂起整条 Agent 流,等用户确认(第四篇)。
- Permission / Guardrails(权限 / 护栏) :约束 Agent「能做什么、不能做什么」的规则体系。AgentScope 2.0.1 自带
PermissionEngine权限裁决能力(记忆印证),本项目在其上构建了 HITL 网关与命令审批。 - ConfirmResult(确认结果) :HITL 恢复时回传给框架的「用户决策」载体(允许/拒绝/永久允许)。
2.5 Skill 与渐进式披露
- Skill(技能) :可复用、可积累的「能力单元」,通常是一段结构化的知识或操作规程(SOP)。本项目 Skill 由 AgentScope 的
AgentSkill承载,落盘为文件 + 元数据入库。 - Progressive Disclosure(渐进式披露) :不把所有 Skill 全文一次性塞进上下文,而是先给模型「目录(名称 + 简述)」,让模型按需拉取全文。这是应对上下文稀缺的关键技巧,本项目实现了「混合式渐进披露」(第六篇是全文重点)。
2.6 流式与前后端交互
- Streaming(流式) :模型逐 token 产出、逐段下发给前端,形成「打字机」效果。
- SSE(Server-Sent Events,服务器推送事件) :一种基于 HTTP 的单向流式推送协议,比 WebSocket 更轻量,天然适合「服务端持续推、客户端只收」的对话场景。本项目用 SSE 承载流式对话(第四篇)。
- 幂等(Idempotency) :同一请求无论执行多少次,效果都与执行一次相同。在「重试可能重复计费/重复落账」的 Agent 场景尤为关键。本项目实现了请求级幂等四态状态机(第四、五篇)。
2.7 MCP(模型上下文协议)
- MCP(Model Context Protocol,模型上下文协议) :一种标准化协议,让 Agent 以统一方式连接「外部工具/数据源服务器(MCP Server)」。本项目通过 MCP 接入 Git 只读查询与实时日志查询两个 Server(第三、四篇)。
2.8 DDD(领域驱动设计)相关
- DDD(Domain-Driven Design,领域驱动设计) :一种以「业务领域」为核心组织代码的方法论。
- 依赖倒置(Dependency Inversion) :高层(领域)不依赖低层(技术实现),二者都依赖「抽象(接口)」。本项目让 infra 依赖 domain(而非相反),是其体现。
- 端口—适配器(Ports & Adapters / 六边形架构) :领域定义「端口(接口)」,技术层提供「适配器(实现)」。本项目 domain 的一组
Service/Gateway接口即端口,infra 的*Impl即适配器(第三篇详述)。
(下一篇:第三篇 骨架——DDD 四模块分层与依赖倒置。)
第三篇 骨架:DDD 四模块分层与依赖倒置(端口—适配器)
本篇讲清楚「代码是怎么组织的」。理解了骨架,后续所有链路才有落脚点。
3.1 理论:为什么要分层与依赖倒置
传统三层架构里,业务逻辑(Service)直接依赖持久化实现(DAO/Mapper)、Web 框架,导致:换数据库要改业务代码、单元测试离不开真实中间件、框架升级牵一发动全身。
DDD + 依赖倒置的思路是:把业务领域(domain)放在最核心、最稳定的位置,让它只定义抽象(端口接口) ,把所有「易变的技术细节」(数据库、Web、消息、外部服务)推到外围,由外围依赖领域并实现其接口(适配器) 。
一句话:依赖箭头永远指向领域内核。
3.2 本项目的四模块与依赖方向(pom 印证)
通过各模块 pom.xml 的依赖声明可以确证依赖方向:
starter/pom.xml依赖clientclient/pom.xml依赖infra,client/pom.xml依赖domaininfra/pom.xml依赖domaindomain/pom.xml不依赖任何内部模块
因此依赖关系是:
注意关键一环:infra → domain。技术实现层反过来依赖领域层,这正是「依赖倒置」的落地——domain 定义接口,infra 实现接口。domain 自身干净,不含任何 Web/数据库依赖。
3.3 端口—适配器:domain 定义接口,infra 提供实现
domain 层 com.joker.domain.agentScope2 包下定义了一组「端口」(纯接口),职责清晰:
| 端口接口(domain) | 职责 | 适配器实现(infra) |
|---|---|---|
ConversationLogService | 对话流水账读写 | ConversationLogServiceImpl |
ExperienceMemoryService | 经验记忆检索/落库 | ExperienceMemoryServiceImpl |
RequestIdempotencyService | 请求级幂等四态 | RequestIdempotencyServiceImpl |
SessionMetaService | 会话元数据 CRUD | SessionMetaServiceImpl |
SessionSkillService | 会话级 Skill 选择态 | SessionSkillServiceImpl |
SkillMetaService | Skill 元数据 | SkillMetaServiceImpl |
PermissionGateway | HITL 权限网关 | (domain 内含逻辑,见第四篇) |
StreamSink | 技术中立的流式下沉 | SseStreamSink(client 层桥接) |
以幂等端口为例,RequestIdempotencyServiceImpl 头部注释明确写道:实现 domain 端口 RequestIdempotencyService,持有 RequestIdempotencyMapper,实体不出 infra 边界,对外只暴露嵌套视图 GateResult / IdempotencyView。这正是端口—适配器的教科书式落地:领域只认接口与只读视图,MyBatis 实体被严格挡在 infra 内部。
3.4 只读视图(DTO record):领域不泄露技术实体
domain 层用一批 Java record(如 GateResult、IdempotencyView、SkillView、SessionView、MessageView、HotQuestion、ConversationLogRow、SkillSelection)作为「领域只读视图」。价值:
- 隔离:infra 的 MyBatis-Plus 实体(
RequestIdempotency等)绝不越过 infra 边界,domain 与 client 只见到不可变的record。 - 表达力:
record天生不可变、字段语义清晰,适合作为跨层传输的「数据契约」。
例如 RequestIdempotencyService.enter(...) 返回 GateResult record(内含 Outcome 枚举 + 结果文本 + 结束原因),Web 层据此分流,全程不接触数据库实体。
3.5 技术中立接口:StreamSink 让 domain 摆脱 Web 依赖
流式对话本质是「把事件推给前端」,但 domain 绝不能依赖 spring-webmvc(否则领域被 Web 技术污染)。解法是定义技术中立接口 StreamSink:
- domain 的
AgentService只面向StreamSink编程:send(name, payload)/complete()/error()/onTerminate(...)。 - 真正的
SseEmitter与 JSON 序列化在 client 层的SseStreamSink里实现——把 payload 用ObjectMapper序列化后经SseEmitter.event().name(name).data(json)发出。
这样领域逻辑与「用 SSE 还是 WebSocket」彻底解耦,未来换传输协议只需改 client 层适配器。
3.6 一个被架构逼出来的关键决策:per-session Agent
问题:不同会话可能启用不同 Skill/工具集,理想是「单例 Agent 运行时切换工具包」。但反编译 AgentScope 2.0.1 证实(记忆沉淀):ReActAgent 的 Toolkit 在 Builder 构造期被 final 绑定,call() 没有 setToolkit,单例 Agent 无法运行时切换 per-session Toolkit。
证据(配置级铁证) :DeepSeekAgentConfig 被刻意做成不产出任何 Bean 的废弃占位类(final class + 私有构造),注释明确记录「移除单例 ReActAgent Bean」的原因。
解法(方案 A) :改为每会话独立构建 ReActAgent + Toolkit,用 LRU 缓存(容量 256)复用。无状态可共享的部分仍是单例——见 DeepSeekModelConfig(OpenAIChatModel + DeepSeekFormatter + stream(true))。
第四篇 血脉:后端核心链路(SSE 流式 / HITL / 命令审批 / 幂等四态)
本篇是全文最核心的一篇,逐条拆解「一次对话请求在后端到底经历了什么」。四条链路环环相扣,任何一环缺失都会导致 SSE 断裂、会话卡死或重复计费。
4.1 理论:为什么对话要用「流式」而非一次性返回
LLM 是逐 token 生成的。若等全文生成完再返回,用户要盯着空白等好几秒,体验极差。流式(Streaming) 让 token 一边生成一边推给前端,形成「打字机效果」。
Web 侧的流式实现有两条主流路线:
- WebSocket:全双工,适合双向频繁交互,但协议重、需要额外的心跳/重连治理。
- SSE(Server-Sent Events) :基于 HTTP 的单向服务器推送,浏览器原生
EventSource支持,轻量。对话场景「服务器推 token、客户端偶尔发指令」正好契合。
本项目选 SSE。Spring MVC 用 SseEmitter 承载。
4.2 SSE 事件协议:前后端约定的「事件语言」
后端通过 SseEmitter.event().name(事件名).data(JSON) 推送带具名事件的 SSE 帧,前端 EventSource.addEventListener(事件名) 分别监听。本项目约定的事件名(源自 domain 的 sendEvent):
| 事件名 | 语义 | 载荷要点 |
|---|---|---|
session | 本轮流的会话/请求标识 | sessionId、requestId |
skill | 本轮参考的沉淀 SOP(透明化) | skills[](name/source/hitCount) |
token | 增量文本(打字机) | 文本片段 |
tool | 工具调用轨迹 | 工具名/入参/结果 |
ask | HITL 权限确认请求 | askId、待确认工具描述 |
command_confirm | 命令审批请求 | 待执行命令 |
done | 本轮终结信号 | 流式不带 text;重放带 replay=true |
error | 出错 | code、message |
学习要点:
done是刻意补发的显式终结信号。历史 Bug:正常完成时只靠关闭 SSE 连接隐式终结,前端无法区分「本轮完成」和「连接异常中断」。现在统一以done事件收口。
4.3 主链路:chatStream 从入口到泵送
一次流式对话的调用栈是「client 入口 → domain 编排 → SDK 事件流 → 泵送回 SSE」:
① Web 入口(client) AgentController.chatStream:
userId从UserContext.get()取(JWT 拦截器已写入,见第 4.7 节)。- 幂等闸口必须在建
SseEmitter之前判定(原因见 4.6)。 - 建
SseEmitter(0L)(永不超时)→ 用SseStreamSink桥接 → 存入activeSinks保活表 → 注册onTerminate摘除防泄漏。 try { agentService.chatStream(userId, req, sink); } catch { markFailed + error 事件 + complete }——这是 Bug2 修复(见 4.6)。
② 领域编排(domain) AgentService.chatStream,顺序严谨:
- 入口自愈:若检测到会话残留 HITL 挂起态,先补发 DENY 走一次
streamEvents收口(否则 SDK 里 ASKING 态会污染新对话)。 sessionMetaService.touch刷新活跃时间。- 先落 USER 提问账,并计算语义指纹
fingerprint(同一算法贯穿经验闭环)。 - 发
session事件。 - Skill 混合式渐进披露注入(
SkillInjectionService.resolveAndRender):pinned 完整正文直注 + 其余目录段;任何异常降级为空注入,绝不打断主流。 - 真正送模型的文本 = Skill 注入段 + 用户原文(但落账/指纹仍用原文)。
agent.streamEvents(modelMessage, ctx)拿到Flux<AgentEvent>,交给subscribeAndPump。
③ 统一泵送 subscribeAndPump:
flux.subscribeOn(Schedulers.boundedElastic())——流在弹性线程池跑,不阻塞 Reactor 主循环,也不阻塞 Web 请求线程。activeStreams.put(requestId, ...)登记活跃流(供取消/删会话反查)。- 三个订阅回调:
onNext→dispatchEvent按事件类型分发;onError→ 落 ASSISTANT 账 +markFailed+ error 事件;onComplete→ 先判挂起守卫,正常完成才落账 +markDone+ 补发done。
4.4 HITL:把整条流「挂起」再「续传」
理论:HITL(Human-in-the-Loop)指在 Agent 执行危险/敏感动作前,暂停并请求人类确认。本项目里,AgentScope 的 RequireUserConfirmEvent 就是挂起信号。
挂起(难点在 complete 回调的守卫) :当 dispatchEvent 收到 RequireUserConfirmEvent,置位 suspended=true,随后 SDK 流自然 complete。此时 complete 回调必须识别挂起场景并跳过一切「本轮已终结」动作:
- 不落 ASSISTANT 账(否则把半截回复当完整答案写进历史);
- 不
markDone(幂等闸口须保持 PROCESSING,同 requestId 重试仍应 409); - 不
sink.complete()(SSE 必须保活,否则恢复流取不到 sink 直接断裂)。 - 只做:
suspendedReplies.put(requestId, replyBuf)保存挂起前的部分回复,出订阅句柄表,sink与activeStreams登记保留。
历史 Bug(阻断级) :修复前 complete 回调无条件终结本轮,导致挂起即关 SSE、恢复流
activeSinks取不到 sink 抛IllegalStateException——HITL 全链路断裂。
续传 resumeStream(由 POST /agent/permission/reply 触发):
permissionGateway.completeChecked校验归属并取回List<ConfirmResult>。- 取回同一个 sink(HITL 挂起期间前端保活了这条 SSE 连接)。
- 取出
suspendedReplies里挂起前的部分回复作为prelude。 - 构造一条
role=USER、内容空、metadata携带ConfirmResult列表的 恢复 Msg。
实证细节(字节码级坐实) :metadata 的 key 是 SDK 内部硬编码字面量
agentscope_confirm_results,并非任何事件类的公开常量。Agent 二次streamEvents时内部extractConfirmResults从该 key 取回列表 → 校验 → 续传,续写 token 直接追加在prelude之后,保证最终落账/DONE 重放是完整全文而非只有续写段。
4.5 命令审批:与 HITL 不同的第二套机制
execute_shell_command 的审批不走 HITL 挂起流,而是走 CommandApprovalBroker:
| 维度 | HITL 权限确认 | 命令审批 |
|---|---|---|
| 触发 | RequireUserConfirmEvent | execute_shell_command 工具执行前 |
| 对 Agent 流 | 挂起整条流(SDK 持久化 ASKING 态) | 不挂起流,仅阻塞工具线程 |
| SSE 事件 | ask | command_confirm |
| 回复接口 | /permission/reply → resumeStream 二次 streamEvents | /command/reply → 仅唤醒阻塞的工具线程 |
| sink 绑定 | activeSinks 保活复用 | commandApprovalBroker.bindSink 订阅前绑定,流结束三处回调解绑 |
因为工具线程被阻塞期间流不会 complete,所以命令审批不需要挂起守卫;/command/reply 只需唤醒线程返回 {ok}。
4.6 请求级幂等四态:防重复计费与重复执行
理论:网络重试、用户狂点、前端重连都可能让同一个 requestId 被提交多次。若不拦,就会重复调用 LLM(重复计费)、重复执行工具(重复副作用)。幂等保证「同一请求多次提交,效果等同一次」。
本项目用 request_idempotency 表 + 四态状态机:PROCESSING / DONE / FAILED / CANCELLED。核心是 RequestIdempotencyMapper 的四条原子 SQL:
tryOccupy:INSERT IGNORE ... VALUES(..., 'PROCESSING', NOW(3))——返 1=首见占位成功,返 0=已存在。单语句原子判重+占位,无竞态。findByTriple:按userId:sessionId:requestId三元组查当前状态。takeoverLease(CAS 抢占):UPDATE SET PROCESSING+新租约 WHERE 三元组 AND ((status='PROCESSING' AND lease_expire<NOW(3)) OR status='FAILED')——WHERE 条件即 CAS 语义,只有「租约过期的僵尸」或「失败可重跑」才被抢占。finish:UPDATE SET status/finish_reason/result_ref WHERE 三元组 AND status='PROCESSING'——终态不可二次覆盖(双保险)。
租约持有者 = 随机 UUID 的 instanceId,lease-seconds 默认 120s(僵尸自动可被抢占)。
Web 层四态分流(AgentController.chatStream):
FIRST_SEEN→ 放行走正常流程。DONE→ 新建SseEmitter,replaySink补发单条done(replay=true)后 complete,HTTP 200 重放。PROCESSING→ 抛BizException409 → 前端转/chat/status轮询。CANCELLED→ 409。
关键约束(被 SSE 特性倒逼) :幂等判定必须在建
SseEmitter之前完成。因为 SSE 一旦创建即返回 HTTP 200,之后无法再改状态码——想返 409 就来不及了。
Bug2(同步异常崩 500) :
chatStream内 SDK 的FluxCreate订阅体是同步执行的,subscribeOn拦不住构造阶段的同步异常,异常冒泡到 Web 层时text/event-stream没有对应 converter 直接崩 500。修复:入口try-catch把同步异常转成 SSEerror事件 +markFailed+complete,不冒泡。
4.7 鉴权与并发安全:JwtInterceptor 的两条防线
JwtInterceptor 是所有受保护接口的入口守卫:
- preHandle:校验
Bearertoken →jwtUtil.parseUserId(sub)→UserContext.set(userId)(ThreadLocal)放行。缺失/验签失败统一返 401,不泄露具体原因。 - afterCompletion:无条件
UserContext.clear()。
学习要点(并发安全铁律) :
UserContext是 ThreadLocal,Web 容器线程池会复用线程。若不在请求结束时 clear,下一个复用该线程的请求会读到上一个用户的 userId——越权串号。这就是为什么 afterCompletion 必须无条件清理。
4.8 取消与清理:让「停止」和「删会话」不留幽灵
-
主动停止
cancelStream:取消活跃流 + 补发 DENY 收口可能残留的 ASKING 态,返{ok, healed}。历史 Bug:前端只
reader.cancel()不通知后端 → ASKING 态残留 → 会话永久卡死。现在/chat/cancel显式 cancelStream 补发 DENY 收口。 -
删除会话:
DELETE /session/{id}先cancelActiveStreams再级联删数据——否则活跃流还在写已删会话,产生幽灵消息。 -
资源生命周期:
SseStreamSink.onTerminate三回调(onCompletion/onTimeout/onError)统一收敛,domain 侧用AtomicBooleanCAS 保证幂等;MCP 客户端 BeandestroyMethod=close防连接泄漏。
第五篇 根系:数据持久化(短期状态 / 业务表 / Cache-Aside / 月分区)
记忆型 Agent 的价值全在「记得住」。本篇讲清楚数据存在哪、怎么存、怎么读得快、怎么不撑爆库。
5.1 理论:Agent 有几种「记忆」,分别存哪
第二篇已从概念上区分了记忆分层,这里落到存储:
| 记忆类型 | 存什么 | 存哪 | 生命周期 |
|---|---|---|---|
| 短期状态 | Agent 当前会话的完整对话状态(含 ASKING 挂起态) | 官方 agentscope_sessions 表(MySQL 真相源)+ Redis 热缓存 | 随会话 |
| 对话流水账 | 每轮 USER/ASSISTANT 原文 + 工具轨迹 + 指纹 | conversation_log(月分区) | 长期,超窗归档 |
| 经验记忆 | 高发问题提炼的 SOP | experience_entry + Redis 热缓存 | 长期共享 |
| 会话/Skill/用户元数据 | 会话列表、Skill 选择态、账号 | session_meta / session_skill / skill_meta / app_user | 长期 |
重要实证(记忆沉淀) :AgentScope 2.0.1 的
LongTermMemory接口已@Deprecated(forRemoval),跨会话长期记忆必须在应用层自建——这正是conversation_log+experience_entry存在的原因。
5.2 短期状态:官方真相源 + Redis 热缓存(装饰器组合)
短期状态存储已生产化落地,是一个装饰器组合:
- 真相源:官方
MysqlAgentStateStore(写agentscope_sessions表,MySQL 持久,进程重启不失忆)。 - 一级热缓存:官方
RedisAgentStateStore(Lettuce 客户端),读写热点会话状态走内存。 - 组合器:自建
CachedAgentStateStore装饰器——读走 Cache-Aside(先 Redis,未命中回源 MySQL 并回填),写双写。
早期的
JsonFileAgentStateStore方案已废弃(本项目要求一律生产级实现,不用凑合方案:本地已有 MySQL+Redis)。
5.3 八张自建表:schema.sql 全景
官方建 1 张(agentscope_sessions,由 MysqlAgentStateStore 自动建表),本项目自建 8 张(schema.sql):
conversation_log:对话流水账,按月 RANGE 分区(详见 5.4)。request_idempotency:请求幂等四态 + 租约 + 结果重放(独立非分区表,uk_req全局唯一)。experience_entry:经验条目,user_id默认__global__全局共享,trouble_stepsJSON 存有序 SOP。skill_meta:Skill 元数据,hit_count记注入命中次数。session_meta:会话列表/标题/时间,idx_user_updated支持侧边栏按更新时间倒序。session_skill:会话级 Skill 手动选择态(pinned/muted)。app_user:用户账号,password_hash存 BCrypt 哈希绝不存明文,user_id即 JWT 的 sub。conversation_log_archive:流水账归档表,与主表同构但不分区(详见 5.5)。
多租户隔离设计:所有业务表都以
user_id打头组合索引/唯一键,天然多租户隔离,隔离粒度细到session_id。
5.4 conversation_log 的两个精妙设计
① 虚拟线程异步直写:对话流水账不能同步阻塞主对话流(否则拖慢 SSE)。落账用 JDK21 虚拟线程异步直写,配有限重试 + 幂等兜底。idempotency_key = userId:sessionId:msgId:role 四段拼接(约 120 字符,所以列宽 VARCHAR(160) 而非 64),uk_idem 保证重试不产生重复行。
② 按月 RANGE 分区:PARTITION BY RANGE (TO_DAYS(created_at))。
- 为什么分区:流水账是全项目增长最快的表。分区让「按时间清理」变成秒级
DROP PARTITION,而非逐行DELETE(后者慢且产生大量 undo log)。 - 分区键约束:分区表主键必须包含分区键,所以主键是
(id, created_at)而非单id;唯一键uk_idem也必须带上created_at。 - 指纹索引:
question_fingerprint列宽 1000 超过索引前缀上限,故取前 255 字符建前缀索引idx_fp_time。
5.5 数据治理:DataRetentionJob「归档而非丢弃」
DataRetentionJob 负责冷数据治理,遵循「归档而非丢弃」(合规审计/离线统计仍可查):
- 分区滚动:定期新建下月分区(避免所有数据落进
pmax)。 - 冷分区归档:把超保留窗口(默认 90 天,可配)的冷分区整段
INSERT ... SELECT搬入conversation_log_archive。 - 秒级丢弃:
ALTER TABLE ... DROP PARTITION秒级丢弃在线表冷分区。 - 两表清理:清理过期的
request_idempotency等辅助数据。
5.6 经验记忆的热缓存
ExperienceMemoryService 检索经验时也走 Cache-Aside(Redis 热缓存 + MySQL 回源)。experience_entry 的 uk_user_fp (user_id, fingerprint(255)) 保证同一用户同一指纹只有一条,提炼任务对其做 upsert(命中就更新 hit_count/SOP,未命中就插入)。
5.7 ORM 选型与一个隐藏坑
持久化层用 MyBatis-Plus(实体 + Mapper + Config)。
实证坑:分区表/复杂 SQL 场景下 MyBatis-Plus 依赖的
jsqlparser对某些 DDL/分区语法解析会报错,需注意规避(记忆沉淀)。
5.8 状态重建:失忆自愈
若短期状态因故丢失(如缓存与真相源都异常),StateRebuildService 能从 conversation_log 回放历史消息重建 Agent 状态,实现「失忆自愈」。这是把「对话流水账」当作事件溯源日志的典型用法——流水账不仅用于展示和统计,还是状态重建的兜底数据源。
第六篇 灵魂:记忆增强与 Skill 混合式渐进披露
前五篇搭好了「能对话、能记住、能鉴权」的骨架。本篇讲让 Agent「越用越聪明」的两个闭环:Skill 渐进披露(把知识按需喂给模型)与经验沉淀(把高发问题自动固化成 SOP)。
6.1 理论:为什么不能把所有知识一次性塞进 Prompt
Context Window 有限且昂贵。若把几十个 Skill 的完整 SOP 全塞进 system prompt:
- 烧 token:大部分 Skill 与当前问题无关,纯浪费。
- 稀释注意力:无关内容多了,模型反而抓不住重点。
渐进式披露(Progressive Disclosure) 的思路:先给模型一份目录(Skill 名称 + 一句简述),让模型自己判断哪项相关,再按需拉取完整正文。这与人类查工具书的方式一致——先看目录,再翻具体章节。
6.2 本项目的「混合式」渐进披露(设计 18.3)
SkillInjectionService.resolveAndRender 采用混合式策略,注入文本分两段:
- pinned 正文直注段:用户手动置顶的 Skill 视为最高优先级,完整 SOP 正文直接注入本轮上下文(不走目录,不计热度)。
- 目录段:其余 active Skill 只注入「名称 + 简述」目录。模型推理时若判断某项相关,调用
skillRecall工具按名拉取完整正文。
选择态合并公式:最终注入 = (自主命中 ∪ pinned) − muted。
- 请求体带
pinnedSkillIds/mutedSkillIds;两字段均为 null 视为「未携带」,回落session_skill表已存态。 - 只有真正被
skillRecall读到正文的 Skill 才自增hit_count(pinned 直注不计数)——热度反映「真实被参考」而非「被摆上目录」。
透明化:注入结果以 SkillUsage 名单(source=pinned 直注 / auto 关键词推荐)经 skill SSE 事件推给前端,让用户看见「本轮参考了哪些沉淀经验」。
「渐进性」到底在哪(澄清口径):渐进性体现在目录段 → skillRecall 按需拉取正文这一跳;pinned 是用户显式意图,直注不算渐进环节。
容错:整个注入服务内部整体容错,任何异常降级为空注入,绝不打断主对话流。
6.3 经验沉淀闭环:让高发问题自动变成 SOP
理论:同类问题被反复问,说明它值得沉淀成标准解法(SOP)。经验沉淀闭环 = 落账 → 聚合 → 提炼 → 回填,让 Agent 把「解决过的问题」变成「下次直接能用的知识」。
语义指纹贯穿全链路:ExperienceMemoryService.fingerprintOf(message) 用归一化算法把语义相近的问题映射到同一指纹,作为聚合键。极短/纯停用词问题可能得空指纹(按 null 处理,统计侧 IS NOT NULL 过滤)。
闭环各环节:
-
落账(第五篇) :每轮 USER/ASSISTANT 带同一指纹落
conversation_log,同指纹的 USER/ASSISTANT 行配对成「一轮问答」语料。 -
聚合 + 提炼(
ExperienceDistillJob,纯 cron 编排在 client 层):@Scheduled(cron = ${agent.experience.distill-cron:0 0 4 * * ?})——默认每天 04:00 错峰执行。findHotQuestions(since, minHits):窗口 7 天、阈值 5 次、跨用户聚合找高发指纹;limit maxFingerprints(默认 10)。- 对每个指纹
findRecentQaByFingerprint(20)取近 20 行语料(单条截 2000 字符,标[用户]/[助手])。 - 直调
Model.stream(无工具,无需 ReActAgent)阻塞收集 → 解析 JSON(截首{末})。 - 单指纹独立
try-catch,顶层try-catch不向调度线程抛——一个指纹失败不中断整批。
-
回填(
applyDistilled):- 落
experience_entry,user_id = __global__全局共享(所有用户受益)。 - 同时把 SOP 用
SkillFileStore固化为一个 Skill 文件 +skill_meta索引——从此该 SOP 进入 6.2 的渐进披露体系被复用。 - 固化失败不回滚经验(
applyDistilled已成功),仅记 warn 下轮重试覆盖。
- 落
架构合法性:闭环触发端(cron 编排)在 client 层,核心业务规则(
applyDistilled)在 domain 层,依赖方向client → domain合法。
6.4 全篇小结:一次对话如何贯穿所有能力
把六篇串起来,一次典型对话的完整旅程:
- 前端带 JWT 请求
/agent/chat/stream(第四篇 SSE + 第四篇 JWT)。 - 幂等闸口判定 FIRST_SEEN 放行(第四篇 幂等四态)。
- domain 落 USER 账、算指纹(第五篇 分区表 + 第六篇 指纹)。
- Skill 混合式渐进披露注入(第六篇)。
- per-session Agent 跑 ReAct 循环,token 经 SSE 打字机下发(第三篇 方案 A + 第二篇 ReAct)。
- 命中危险工具 → HITL 挂起 / 命令审批(第四篇)。
- 完成 → 落 ASSISTANT 账 + markDone + 补发 done(第四篇 + 第五篇)。
- 凌晨 04:00,高发问题被提炼成 SOP 回填全局经验(第六篇 闭环)。
这套设计的每一处都有据可依(文档或代码),且多数关键决策来自对 AgentScope 2.0.1 的字节码级实证。
第七篇 触角:MCP 工具服务器与「沙箱如何读到最新全量代码」
前六篇讲的都是主应用(Java 侧)自己的骨架、血脉、根系与灵魂。但一个「能诊断线上问题」的 Agent,光靠自己的知识是不够的——它必须能伸出触角去读真实世界的两类事实:代码仓库的最新全量源码,和线上机器的实时日志。这两类事实不在主应用里,而是通过 MCP(Model Context Protocol) 协议,由两个独立的外部服务器提供。本篇把这两个 server 的实现讲透,并回答用户最关心的问题:每一次诊断,Agent 是怎么读到「最新全量代码」的?
7.1 先讲理论:MCP 是什么、为什么要单独起 server
MCP(Model Context Protocol) 是一套「把外部能力标准化暴露给大模型 Agent」的协议。核心思想是:
- 工具即接口:外部系统把自己的能力(查提交、读文件、搜日志……)声明成一组「工具(tool)」,每个工具有名字、参数 schema、文档说明。
- Agent 即调用方:Agent 在 ReAct 循环里根据需要选择工具、填参数、拿结果,完全不关心工具背后是 Python 脚本、shell 命令还是远程 API。
- 传输解耦:本项目两个 server 都用 streamable-http 传输(endpoint 形如
http://127.0.0.1:9101/mcp),Java 侧用McpClientBuilder.create(...).streamableHttpTransport(url).buildSync()同步建连即可。
为什么不把这些能力直接写进 Java 主应用? 三个现实原因:
- 技术栈就近:查 git 用系统
git命令最省事、查京东内网日志平台需要浏览器 cookie,这些用 Python 脚本(fastmcp库)几行就能封装,塞进 Java 反而别扭。 - 权限隔离:git-mcp 可以在协议层强制「只读白名单」,把危险命令挡在主应用之外——即便模型「幻觉」出一个
git push,也过不了 server 的参数校验。 - 可替换性:
mock-mcp目录下这两个是当前的实现(对接了真实 git 仓库和真实日志平台,并非纯 mock);生产上把agent.mcp.git.url/agent.mcp.log.url指向别的 server 即可无缝替换,主应用零改动。
装配落点(一手证据):见 DiagnosisAgentConfig。它在 agent.mcp.enabled=true 时装配 gitMcpClient / logMcpClient 两个容器单例 Bean,destroyMethod="close" 保证容器销毁时释放连接(防泄漏),buildSync() 在应用启动期一次性建连。这两个 client 随后被直接挂到主 Agent 的 Toolkit(见 SessionToolkitManager),不再走诊断子 Agent(第三篇 3.7 已讲过取消子 Agent 的方案 A 与死循环根因)。
7.2 两个 server 一览
| 维度 | git-mcp(:9101) | log-mcp(:9102) |
|---|---|---|
| 源码 | git_mcp_server.py | log_mcp_server.py |
| 库 | fastmcp | fastmcp |
| 传输 | streamable-http /mcp | streamable-http /mcp |
| 数据源 | 本机 clone 的真实 git 仓库(京东 coding) | 京东 DongMonitor 日志平台 HTTP API |
| 认证 | 本机 SSH key(server 跑在本机,非沙箱) | 浏览器抓包 cookie(存本地文件,绝不进对话) |
| 安全红线 | 只读白名单,对远端零写入 | 只读查询,凭证只落本地文件 |
| 工具数 | 11 个 | 4 个 |
| 启动 | start.sh(可 --daemon 后台) | 同上,一键同拉两个 |
7.3 git-mcp:只读白名单 + 环境锁定
7.3.1 只读红线是怎么落地的
git-mcp 的核心安全机制是 白名单模式:只有落在 _READONLY_WHITELIST 里的纯读子命令才放行,白名单外一律拒绝(见 git_mcp_server.py 的集合定义与 _run_git_raw 的校验)。白名单包含 log / show / diff / status / grep / blame / ls-remote / cat-file / for-each-ref 等纯读命令,外加受控放行的 fetch。
除白名单外,还有三道收窄校验:
config收窄:仅放行--get / --list等读参数,写配置被拒。fetch收窄:仅允许git fetch origin(可带--prune/--tags),出现 refspec、+src:dst、--force一律拦截——因为 fetch 对远端仓库是纯读(只下载对象到本机.git、更新本地远端跟踪引用refs/remotes/origin/*),不 push、不建远端分支、不改远端任何 ref。- 危险全局参数兜底:
--exec / --upload-pack= / --git-dir / --work-tree / -c / -C等可触发任意代码或改写仓库位置的参数全部拦截。
一句话:即便模型幻觉出写命令,也进不了 git 仓库。
7.3.2 环境锁定 origin/uat
所有查询工具的默认 ref 锁定在 DEFAULT_REF = origin/uat(见 git_mcp_server.py)。原因很实在:本地只在 uat 环境做日志查询 + 代码检索,二者必须同环境,否则会出现「拿 master 代码去解释 uat 日志」的诊断偏差——git 侧默认 ref 与 log 侧抓包的 uat 日志分组严格对齐。需要临时查其它分支时显式传 branch/ref 参数即可覆盖。
7.3.3 工具面(对齐「查提交 → 看 diff → 定位根因」)
| 工具 | 作用 |
|---|---|
sync_remote | 拉取远端最新全量代码(受控 fetch),见 7.4 |
repo_info | 仓库概况:remote、当前分支、锁定分支最新提交、工作区脏否 |
ls_remote | 协议级查询远端最新分支/tag(零副作用,不写 .git) |
list_branches / list_tags | 列本地已有分支 / 发布 tag(倒序) |
list_recent_commits | 查提交历史(可按 keyword/author/path 过滤) |
get_commit_detail / get_commit_diff | 单次提交详情 + 完整 diff |
get_diff_between | 两个 ref 间 diff(对比两次发布 tag「这次上线改了什么」) |
get_file_content | 读指定 ref 的文件内容,带行号 + 行范围分页 |
search_code | git grep 全仓库搜关键字(日志里的类名 → 源码位置) |
blame_line | 行级追溯(报错第 N 行是谁改的) |
get_file_content 有个值得学的细节(见 git_mcp_server.py):它专门用 _run_git_raw(不截断)先拿到文件全文,再按 start/end 切片;而不是像 _run_git 那样先按 6 万字符截断——否则大文件会「先被砍断再切片,导致后半段永远读不到」。这是「分页读大文件」的正确姿势。
7.4 ★核心问题:每个诊断会话如何读到「最新全量代码」
用户最关心的点,答案由三层机制共同保证:
第一层:数据源是本机一份真实 clone,不是快照
git-mcp 的所有 git 命令都在 REPO_PATH(默认 /Users/liuwang83/Desktop/DynamicRouteBackGround,可用环境变量 GIT_REPO_PATH 覆盖)这个真实 git 仓库目录里执行(git -C REPO_PATH ...)。它读的是活的 .git 对象库,不是某个时间点的静态拷贝。
第二层:sync_remote 主动把远端最新全量代码同步到本机
这是回答「最新」二字的关键工具(见 sync_remote)。它做的事:
git fetch origin --tags [--prune]
- 对远端零写入:只从远端下载最新对象到本机
.git、更新本地远端跟踪引用refs/remotes/origin/*;不 push、不建远端分支、不改远端任何 ref。 - 不动工作区:不 merge、不 checkout,只刷新
.git里的远端跟踪引用。 - 认证走本机 SSH key:git-mcp 跑在本机(非沙箱),可用
~/.ssh,查询无需额外 token。
sync_remote 一旦执行,.git 里 origin/uat 就指向了远端最新提交。此后所有查询工具(get_file_content / search_code / list_recent_commits / diff……)默认 ref 都是 origin/uat,于是天然读到的就是刚同步下来的最新全量代码。
第三层:「每个沙箱都读到最新」的真正含义——共享同一份权威数据源
这里要澄清一个概念:主应用(Java)的会话隔离沙箱(第三篇讲的 per-session workspace baseDir)是主应用侧的,而 git-mcp 是独立进程、面向所有会话共享同一个 REPO_PATH。也就是说:
- 每个用户会话在主应用里有各自的 workspace 沙箱(隔离对话状态、文件工具的 baseDir);
- 但当任一会话的主 Agent 调用 git 工具时,请求都打到同一个 git-mcp(:9101) ,读的是同一份
REPO_PATH仓库; - 「最新」由
sync_remote(git fetch)保证:只要在诊断前调用一次同步,origin/uat就是远端最新,之后每个会话、每次get_file_content读到的都是这份刚刷新的全量代码,无需每个沙箱各自 clone 一份。
为什么这样设计是对的? 代码仓库是全局共享的事实(不是某用户私有数据),用一份权威 clone + 按需 fetch 刷新,既保证「所有会话看到一致且最新的代码」,又避免 N 个会话 clone N 份仓库的巨大开销。隔离留给「会话对话状态」,共享留给「客观代码事实」——各取所需。
注意
ls_remote与sync_remote的分工:ls_remote是协议级只读查询(git ls-remote),只看远端有哪些引用、不下载对象;要真正读到远端新提交的文件内容/diff,必须先sync_remote把对象拉到本机。二者配合:先ls_remote看「远端有没有新东西」,需要看细节再sync_remote拉全量。
7.5 log-mcp:凭证热更新 + 实时日志查询
log-mcp 对接京东 DongMonitor 日志平台的 grep 接口(POST .../log/grep),认证靠浏览器内网 SSO 的 cookie。它最巧妙的设计是凭证热更新(见 log_mcp_server.py 的模块注释与 _load_curl):
- 每次工具调用都重新读取
mock-mcp/log-api.txt,解析其中浏览器「Copy as cURL」抓来的请求(URL、请求头、cookie、默认查询参数 appName/groups/paths/erp)。 - cookie 过期时,用户只需在浏览器重抓一次 cURL 覆盖该文件,即刻热生效,无需重启 server。
- 切换查询环境(uat → 生产组)同理:按目标环境查询一次并重新抓包覆盖即可(groupId 与 group 绑定)。
- 安全红线:所有凭证只存本地文件,
show_config也只显示「cookie 已配置(N 字符)」而绝不回显 cookie 明文进对话。
工具面 4 个:
| 工具 | 作用 |
|---|---|
show_config | 查当前可查询范围(应用/分组/默认路径/ERP),不露 cookie |
query_logs | 按关键字查日志(时间窗、路径、排除词、IP 过滤) |
error_summary | 查错误日志(默认 error.log,最近 N 分钟) |
tail_logs | 看最近 N 分钟日志尾部(正则匹配行首时间戳=全量行) |
log-mcp 的「最新」如何保证? 它本身不缓存,每次调用都实时向 DongMonitor 平台发起 grep 请求,时间窗默认「最近 N 分钟」(如 tail_logs 默认 5 分钟、error_summary 默认 60 分钟),因此拿到的永远是平台侧的实时日志——「最新」由「每次实时查、不缓存」保证,和 git 侧「fetch 刷新一份共享 clone」是两套不同但都成立的机制。
7.6 启动与主应用对接
- 一键启动:
start.sh同时拉起两个 server;用.venv虚拟环境自举(Homebrew Python 受 PEP 668 管控不能直接 pip install),并做端口占用自检;--daemon后台运行 + 端口就绪等待,--stop停止。 - 主应用侧配置:
application.properties里agent.mcp.enabled=true、agent.mcp.git.url=http://127.0.0.1:9101/mcp、agent.mcp.log.url=http://127.0.0.1:9102/mcp,与两个 server 的 endpoint 严格对齐。 - 调用主体:主 Agent 在 ReAct 循环里,遇到「诊断类」问题时自主决定调
list_recent_commits/search_code/error_summary等工具,把「读代码」与「读日志」的事实喂给模型进行根因推理——这正是第一篇所说「能诊断线上问题的记忆型 Agent」的能力来源。
7.7 小结
| 关注点 | git-mcp | log-mcp |
|---|---|---|
| 「最新」怎么来 | sync_remote(git fetch)刷新共享 clone 的 origin/uat | 每次实时查平台,不缓存 |
| 隔离/共享 | 全会话共享同一份权威 clone(代码是全局事实) | 全会话共享同一日志平台入口 |
| 安全 | 只读白名单,对远端零写入 | 凭证只落本地文件,绝不进对话 |
| 环境一致性 | 锁定 origin/uat,与日志环境对齐 | 抓包绑定 uat 分组,与代码环境对齐 |
至此,「每个诊断会话如何读到最新全量代码」的完整答案是:代码事实由一份共享的真实 clone 承载,sync_remote 通过 git fetch(对远端零写入)把它刷新到远端最新,所有会话查询默认锁定 origin/uat 从而一致地读到这份最新全量代码;会话隔离留给主应用侧的对话状态,代码这类全局客观事实则共享一份权威源。
第八篇 深潜:短期状态的「重建」与两级缓存的「一致性」
第五篇已经讲清了短期状态存储的结构(CachedAgentStateStore = Redis 热缓存 + MySQL 真相源)与失忆自愈的定位。本篇是对两个最容易被问倒的细节的正面回答,也是把附录待办 B、C 落到源码的一次深潜:
- B:
StateRebuildService回放重建时,究竟读哪些字段、tool_trace到底参不参与、如何映射回 SDK 内部状态?- C:
CachedAgentStateStore双写时 MySQL 与 Redis 的失败如何补偿、缓存的 TTL 到底是多少?全部结论均以
StateRebuildService、CachedAgentStateStore、AgentStateStoreConfig三处源码为准,不作臆测。
8.1 理论:为什么需要「重建」——事件溯源思想的一次落地
有状态服务永远绕不开一个问题:承载状态的存储损坏了、被误清了、或过期淘汰了,用户再进来怎么办?
业界的成熟答案是事件溯源(Event Sourcing) :不把「当前状态」当作唯一真相,而是把「导致状态变化的一连串事件」持久化下来;当状态丢失时,从事件流**重放(replay)**即可重建出等价的当前状态。
把这套思想映射到本项目:
| 事件溯源概念 | 本项目对应物 |
|---|---|
| 事件流(append-only 的历史事实) | conversation_log:每轮 USER/ASSISTANT 双行落账,只增不改 |
| 当前状态(可重建、可丢弃的投影) | Agent 短期状态 AgentState(存于 agentscope_sessions + Redis) |
| 重放引擎 | StateRebuildService |
| 重放触发条件 | 状态存储 miss(损坏/误清/TTL 淘汰后续聊) |
关键洞察:短期状态是「可丢的投影」,流水账才是「不可丢的事实」。这正是文档 13.3 把 conversation_log 称作「最后一道防线」、把重建称作「双保险」的原因——真相源即便整个丢了,只要流水账还在,历史对话就能续得上。
8.2 回放的精确口径(回答附录 B)
8.2.1 触发时机:只在 cache-miss 且有历史时才重建
rebuildIfMissing 的第一步就是 stateStore.exists(userId, sessionId)——存在即短路返回,零重建开销。这步 exists 走的是 CachedAgentStateStore:Redis 命中即短路,未命中才落 MySQL,成本可控。
调用点在 SessionAgentManager.acquire 的 cache-miss 分支、buildAgent 之前:只有当进程内 Agent 缓存也没有该会话时,才可能需要重建。若 Agent 已在本进程内存中(cache hit),其内存态就是最新真相、结束时会自己回写 store,此时绝不重建(否则会用旧历史覆盖进行中的现场)。
三条分支的完整语义:
exists == true → 状态还在,直接返回(正常路径)
exists == false 且有流水账 → 回放重建 AgentState 并预写 store(失忆自愈)
exists == false 且无流水账 → 真正的新会话,什么都不写,交给框架首轮 call 建 fresh state
8.2.2 读哪些行:只回放「干净」的对话语料
重建数据来自 conversationLogService.findReplayableMessages(userId, sessionId, maxReplayMessages),其口径在 StateRebuildService 的类注释里写得很明确:
- 只取 USER 行(
finish_reason IS NULL)与正常完成的 ASSISTANT 行(finish_reason = 'stop'); error/cancelled的残缺回复不进上下文——避免把「一次失败/被打断的半截回答」当成有效语料污染续聊;- 超长截尾:只取最近
agent.state.replay-max-messages(默认 200,见构造器@Value默认值)行,防极端长会话把重建后的上下文撑爆模型窗口;构造器还用Math.max(2, ...)兜了下限。
8.2.3 tool_trace 到底参不参与重建?——不参与
这是附录 B 最核心的疑问,源码给出确定答案:tool_trace 不回放。
StateRebuildService 类注释原文:「toolTrace 不回放——它是给前端展开的轨迹 JSON,非对话内容。」 回放只用到流水账行的 role 与 content 两个字段(见 toMsg),tool_trace 列压根没被读取。
理由清晰:tool_trace 是给前端 replayTrace 做历史轨迹回显用的(重进会话时按序重现 📘/🔧 图标),它属于「展示层的附加信息」,不是「模型上下文的一部分」。重建的目标是让模型能接着聊,而不是让前端能重画轨迹——这是两件事,用两份数据。
8.2.4 如何映射回 SDK 内部状态:预写 agent_state,让框架自动加载
重建产物是一个框架的 AgentState,组装逻辑见 buildAgentState:
AgentState.builder()
.userId(userId)
.sessionId(sessionId)
.context(messages) // USER/ASSISTANT 交替的纯文本 Msg 序列
.build();
- 只重建
context对话缓冲:把流水账行逐条toMsg成Msg.builder().role(...).textContent(...)(纯文本内容块),USER/ASSISTANT 交替; summary/replyId/curIter等运行态字段一律留空——它们属于「进行中一轮」的现场,跨重启恢复本就不该续用。重建的语义是「历史对话可续聊」,不是「中断现场可续跑」(HITL 的中断恢复另有agentscope_confirm_results机制,不走这条兜底)。
映射回 SDK 的方式不是反射篡改 Agent 内部,而是预写:把重建的 AgentState 用 stateStore.save(userId, sessionId, AGENT_STATE_KEY, rebuilt) 写进 store,key 为硬编码的 "agent_state"(见 AGENT_STATE_KEY)。框架 2.0.1 的 ReActAgent 每轮调用时会惰性执行 loadOrCreateAgentStateForSlot,按 stateStore.get(userId, sessionId, "agent_state", AgentState.class) 读取——所以我们只要在 Agent 首次使用之前把状态预写进去,首轮 call 就会自动加载,无需任何反射干预。
⚠️ 一个升级风险:
"agent_state"是框架内部硬编码字面量,官方未提供公开常量,本项目以同名常量对齐(源码注释已标注「升级框架版本时需复核」)。
8.2.5 容错红线:重建失败绝不阻断主流程
rebuildIfMissing 整个方法体裹在 try/catch 里,任何异常(流水账查询失败、状态写入失败等)只记 warn 不抛出——退化为「全新会话」体验,绝不阻断用户的主对话。这正是「双保险兜底」应有的姿态:兜底本身失败,最坏也只是回到没有兜底的状态,不能反过来把主流程拖垮。
并发安全上也做了考量:save 是全量替换语义,即便两个线程同时命中 miss 一起重建,也只是重复写同一份内容,最终一致。
8.3 两级缓存的失效与一致性(回答附录 C)
8.3.1 一致性总原则:MySQL 真相源,Redis 只加速
CachedAgentStateStore 是一个装饰器,对外暴露单一 AgentStateStore 契约,内部组合 MySQL(真相源)+ Redis(热缓存)。类注释把策略写得很干脆:MySQL 为 source of truth,Redis 仅加速。
这条原则决定了所有读写的失败补偿方向——任何 Redis 操作失败都只告警、绝不影响正确性,因为真相永远在 MySQL。
8.3.2 写:先落真相源,再写缓存(缓存失败不阻断)
save 的顺序是先 MySQL 后 Redis:
mysql.save(userId, sessionId, key, state); // 真相源:异常向上抛,保证不丢
try {
redis.save(userId, sessionId, key, state); // 热缓存:失败仅 warn,不阻断
} catch (Exception e) {
log.warn("[state-cache] redis save failed ... fallback to mysql only", ...);
}
- MySQL 写失败 → 异常向上抛,本轮状态保存失败可被上层感知(保证不丢是第一优先级);
- Redis 写失败 → 只告警,退化为「本次没写进缓存」,下次读会 cache-miss 回落 MySQL 再回填,自愈。
列表版 save(..., List<State>) 同理。这是典型的 Cache-Aside 写路径:真相源必须成功,缓存尽力而为。
8.3.3 读:Cache-Aside,未命中回落并回填
get 是标准 Cache-Aside:
- 先查 Redis,命中即返回;
- Redis 查询异常也只告警,继续往下走;
- 回落 MySQL,若查到则回填 Redis(回填失败仅告警),下次即可命中。
getList 逻辑完全一致(命中判定为「非空列表」)。这样即使 Redis 整个挂了,读路径也只是每次都走 MySQL——慢一点,但永远正确。
8.3.4 exists / delete / listSessionIds 的口径
exists:Redis 命中即短路返回true,否则以 MySQL 为准(这也是 8.2.1 中重建判定「零成本短路」的底气);delete(整会话 / 指定 key 两个重载):先删 MySQL 再删 Redis,Redis 删失败仅告警——即便缓存残留脏数据,下次读也会因 MySQL 已删而返回空,不会读到幽灵;listSessionIds:直接以 MySQL 为准,Redis 不做会话枚举缓存;close:先关 Redis(失败仅告警)再关 MySQL,保证真相源资源必然释放。
8.3.5 关于「TTL 具体值」——诚实的核对结论
附录 C 原本想核对「缓存 TTL 具体值」。逐行核对 AgentStateStoreConfig 的实际装配后,结论需要如实修正:
RedisAgentStateStore.builder()
.lettuceClient(stateRedisClient)
.keyPrefix(redisKeyPrefix) // 默认 "sess:",最终 key 形如 sess:{userId}:{sessionId}
.build();
当前代码并未在 RedisAgentStateStore.builder() 上显式配置任何 TTL / expire 参数——只设了 lettuceClient 与 keyPrefix。这意味着:
- 缓存条目的过期行为取决于官方
RedisAgentStateStore的默认实现(是否有内置默认 TTL,需以该扩展包源码为准),而非本项目在配置层显式指定; - 唯一在本项目侧显式配置的时间参数是
RedisURI上的Duration.ofSeconds(5)连接超时(见stateRedisClient),这是连接超时、不是 key TTL,两者不可混为一谈。
📌 修正说明:先前把「Redis
sess:前缀 TTL 自动淘汰」当作既定事实是不够严谨的——本项目配置层并未显式设 TTL。真正稳固的一致性保证不来自 TTL,而来自**「MySQL 真相源 + 缓存 miss 必回落 + 被清会话由StateRebuildService回放自愈」**这一整套机制:即便某条缓存永不过期或提前消失,正确性都不受影响。若确需给热缓存加显式 TTL,应在RedisAgentStateStore.builder()处补充对应参数,此处留作可选优化项。
8.4 小结:投影可丢,事实不丢
| 维度 | 结论 | 源码依据 |
|---|---|---|
| 重建触发 | 仅 cache-miss 且流水账有历史 | rebuildIfMissing |
| 回放语料 | 只取 USER + stop 的 ASSISTANT,截尾 200 | 类注释 + findReplayableMessages |
| tool_trace | 不参与重建,仅前端回显用 | toMsg 只读 role/content |
| 状态映射 | 预写 agent_state,框架首轮 call 自动加载 | AGENT_STATE_KEY |
| 写一致性 | 先 MySQL(抛错)后 Redis(仅告警) | save |
| 读一致性 | Cache-Aside:命中返回,miss 回落并回填 | get |
| 删一致性 | 先删 MySQL 后删 Redis,残留不致读脏 | delete |
| Redis TTL | 配置层未显式设置,正确性不依赖 TTL | redisAgentStateStore |
一句话收束:短期状态是可丢的投影,流水账是不可丢的事实;Redis 快而不可靠、MySQL 慢而权威,一切失败补偿都朝真相源收敛,一切失忆都由回放自愈。
第九篇 前端四个「不得不讲」的交互细节
全文按用户要求对前端「一笔带过」,把笔墨留给后端与持久化。但有四个交互细节,它们不是纯 UI 问题,而是分布式契约在浏览器端的另一半落地——不讲清楚,后端的 SSE / HITL / 幂等 / 鉴权就只有半张图。本篇专门补白附录 E 列出的四点,全部以
index.html(对话主界面)源码为据。前置说明:本项目前端是「Thymeleaf 首屏骨架 + 纯 JS 交互」,鉴权/SSE/多会话逻辑全走 JS,
fetch携带 Bearer token(见页首注释)。SSE 不用EventSource,而是fetch + getReader + TextDecoder按\n\n分帧——因为EventSource不支持自定义 Authorization 头,且不支持 POST body。
9.1 全局 401 拦截:一个 api() 封装收口所有鉴权失败
后端 JwtInterceptor 对所有业务接口校验 token,失效即 401。前端不能让每个请求各自处理 401,于是用一个统一封装 api() 收口:
async function api(url, options = {}) {
const opts = Object.assign({ headers: {} }, options);
opts.headers = Object.assign({
'Authorization': 'Bearer ' + token, // 自动带 Bearer token
'Content-Type': 'application/json'
}, opts.headers);
const resp = await fetch(url, opts);
if (resp.status === 401) { // token 失效:清理并跳登录
localStorage.removeItem('token');
location.href = '/login';
throw new Error('unauthorized');
}
return resp;
}
要点:
- 单一入口:所有业务请求都走
api(),token 注入与 401 处理只写一次; - 失效即收敛:401 → 清
localStorage的 token → 跳/login→ 抛异常中断后续逻辑,避免拿着废 token 继续跑; - 首屏守卫:页面加载时
if (!token) { location.href = '/login'; },无 token 直接挡在门外(与 ViewController 的服务端跳转形成双保险)。
这与后端 JwtInterceptor 构成完整闭环:后端负责判定失效,前端负责统一善后。
9.2 停止生成:不只是「切断前端」,更要「通知后端收口」
这是四个细节里最容易做错、也最能体现分布式思维的一个。
用户点「停止」时,天真的做法是「把浏览器端的 SSE 读取取消掉」就完事。但本项目的 stopGenerating 做了两步:
async function stopGenerating() {
// 1) 切断浏览器端 SSE 读取(立即停止前端渲染)
if (currentReader) {
try { await currentReader.cancel(); } catch (e) { /* 忽略 */ }
currentReader = null;
}
// 2) 通知后端主动取消该会话
if (currentSid) {
try {
await api('/agent/chat/cancel', {
method: 'POST',
body: JSON.stringify({ sessionId: currentSid })
});
} catch (e) { /* 静默 */ }
}
setStatus('done', '⏹ 已停止');
setGenerating(false);
}
第二步为什么不可省? 源码注释记录了一个真实修复:「修复前仅切断前端读取、从不通知后端,导致 Agent 挂起在权限确认时被『停止』后其 ASKING 态永久残留,该会话下次发消息即报错卡死。」
也就是说:如果只切前端,后端那个正挂起在 HITL 权限确认的 Agent 并不知道用户已放弃,它的 ASKING 态会永久残留,把整个会话锁死。所以停止必须按 sessionId 维度通知后端 /agent/chat/cancel——后端会取消活跃流 + 对残留 HITL 挂起态补发 DENY 收口。取消通知失败则静默(不阻断前端 UI 收尾)。
配套的 setGenerating 管理发送/停止按钮互斥,并做了终态兜底:若停止时状态条仍停在「进行中」(没收到 done),收敛为静态「⏹ 本轮已结束」以停掉脉冲动画。生成中还禁止切换会话(第467行),避免流串到别的会话。
9.3 历史回显:让「重进会话」和「实时对话」看起来一模一样
第八篇讲过 tool_trace 不参与状态重建、只供前端回显。这里就是它的消费端。
打开会话 openSession 拉 GET /agent/session/{id}/messages,返回 [{role, content, toolTrace, createdAt}],逐条回显时对助手消息做了关键处理:
if (m.toolTrace) { replayTrace(m.toolTrace); } // 先原样重现过程轨迹
setBubbleContent(bubble, m.content || '', 'assistant'); // 再显示回复正文
replayTrace 把落库的 tool_trace JSON 解析成 📘/🔧 条目,文案与实时流的 handleEvent(tool/skill 分支)完全一致,保证「思考在上、结论在下」的版式与实时对话一模一样:
type === 'skill'→📘 参考经验「名称」 来源[手动置顶/自主命中] 已命中 N 次;type === 'tool'→🔧 调用工具 X或✓ X 完成。
工程上两个细节体现了成熟度:
- 兼容脏数据:
JSON.parse失败或结构不是数组直接return,跳过轨迹但不阻断正文回显——历史消息的正文永远能显示,哪怕轨迹坏了; - 大小写兼容:历史接口返回大写
USER/ASSISTANT,实时流传小写user/assistant,渲染处统一toLowerCase()判定,两条数据来源共用一套渲染。
9.4 Skill 选择态持久化:按会话维度,双写前后端
技能的「置顶/屏蔽」是每个会话独立的选择,且要在刷新、切换会话后仍然保持。本项目用「按 sessionId 双写」实现,保存逻辑见 saveSkillSelection:
skillSelection = { pinnedSkillIds, mutedSkillIds };
localStorage.setItem('skill.' + currentSid, JSON.stringify(skillSelection)); // 本地按会话存
if (currentSid) {
await api('/agent/session/' + currentSid + '/skills', { // 同步后端
method: 'PUT', body: JSON.stringify(skillSelection)
});
}
- key 按会话隔离:
localStorage的 key 是skill.{sessionId},天然区分不同会话的选择态; - 打开会话即恢复:
openSession里localStorage.getItem('skill.' + sid)先从本地恢复,缺失则回默认空选择; - 双写后端:同时
PUT /session/{id}/skills落库,使选择态跨设备/跨浏览器也能一致(本地只是快取); - 发送时上送:
sendMessage组装 body 时带上pinnedSkillIds/mutedSkillIds(第999行),交给后端SkillInjectionService做混合式渐进披露(呼应第六篇)。
9.5 小结:前端是分布式契约的另一半
| 交互细节 | 本质 | 关键实现 |
|---|---|---|
| 全局 401 拦截 | 鉴权失效的统一善后 | api() 单入口注入 token + 401 跳登录 |
| 停止生成 | 不只切前端,更要通知后端收口 HITL | stopGenerating 两步:cancel reader + /chat/cancel |
| 历史回显 | tool_trace 的消费端,与实时版式一致 | replayTrace 复用实时文案 + 脏数据跳过 |
| Skill 选择态 | 按会话隔离 + 双写前后端 | key skill.{sid} + PUT /session/{id}/skills |
一句话收束:前端「一笔带过」不等于「无关紧要」——SSE 的取消、HITL 的收口、tool_trace 的回显、幂等 requestId 的生成,都是后端契约在浏览器端的另一半;少了这一半,分布式的正确性就是不完整的。
第十篇 收尾:优雅停机——在途请求、状态落盘与挂起 HITL 的有序收敛
本篇回答附录 D。它把
com.joker.client.job里两个常被误认成「一对」的类拆开讲清楚:GracefulShutdownLifecycle是停机编排,PermissionGatewayCleaner是运行期定时清理——两者做的根本不是同一件事,只是恰好都住在job包里。
10.1 为什么 kill -9 对一个 Agent 服务是灾难
先立理论。一个无状态 CRUD 服务被强杀,最坏不过丢几个在途 HTTP 响应,客户端重试即可。但记忆型 Agent 服务被强杀,会同时踩中三颗雷:
- 在途 Agent call 的状态未落盘:框架
AgentBase在每次call结束时才stateStore.save(...)。一次多轮 ReAct 推理可能耗时数秒到数十秒,中途被杀,这一轮的AgentState投影就丢了——下次进来从上一个检查点重放,用户看到「刚说的话它忘了」。 - 在途 SSE流被硬断:浏览器端的
reader收到的是半截事件流,前端状态机悬在「生成中」。 - 异步流水账未落库:对话流水账走虚拟线程异步直写
conversation_log(见记忆定案),强杀会丢掉队列里未落库的事实事件——而事实事件是不可丢的(第八篇事件溯源口径)。
所以「优雅停机」的目标非常具体:停收新请求 → 等在途 call 收尾并落盘 → 排空异步流水账 → 才真正退出。这是一个有严格先后的收敛序列。
10.2 框架已经自带停机机制,为什么还要应用层再写一个
这是本篇最关键、也最反直觉的一点。AgentScope 2.0.1 的框架单例 GracefulShutdownManager 本身就注册了 JVM 的 SIGTERM 钩子:停机时它会等在途请求、超时中断、并经 ShutdownStateSaver 把中断态落盘(shutdownInterrupted=true,下次调用自动续跑)。日志里能看到它的身影:
GracefulShutdownManager - Graceful shutdown initiated, 0 active request(s), timeout=infinite
agentscope-jvm-shutdown-hook ...
看起来框架全包了。但 GracefulShutdownLifecycle 的类注释点破了一个致命时序缺口:
JVM 钩子在 Spring 容器销毁之后才执行,而 stateStore 底层的 HikariCP 连接池是 Spring 管理的 Bean——届时池已关闭,
ShutdownStateSaver的落盘会全部失败,等于优雅停机形同虚设。
一句话:框架想落盘时,数据库连接池已经没了。 应用层这个类的全部意义,就是把「等在途、中断、落盘」这套动作,从「JVM 钩子(太晚)」**提前到 Spring 的 SmartLifecycle.stop() 阶段(连接池仍存活)**去做。
10.3 GracefulShutdownLifecycle:把停机塞进 Spring 生命周期的正确时刻
它实现 SmartLifecycle,靠一个 phase 值卡位:
@Override
public int getPhase() {
return SmartLifecycle.DEFAULT_PHASE - 100; // 略低于 Tomcat graceful
}
Spring 停机时按 phase 从高到低依次 stop。Tomcat 的 graceful shutdown 用的是 DEFAULT_PHASE(Integer.MAX_VALUE),所以:
- 先 Tomcat(phase 高) :停收新 HTTP 请求,等在途请求(含 SSE 流)完成或超时断开;
- 再本类(phase 低 100) :此刻已无新请求涌入,安全接管 Agent 层停机。
stop() 内部是四步串行收敛(源码 stop()):
| 步 | 动作 | 关键点 |
|---|---|---|
| ① 设有限超时 | manager.setConfig(new GracefulShutdownConfig(Duration.ofSeconds(30), PartialReasoningPolicy.SAVE)) | 框架默认 shutdownTimeout=null(无限等待),一旦有流卡死整个停机永久挂起,故必须收敛成有限超时;SAVE = 超时中断时保留部分推理内容 |
| ② 触发停机 | manager.performGracefulShutdown() | 状态 RUNNING → SHUTTING_DOWN,此后新 call 被 ensureAcceptingRequests() fail-fast 抛 AgentShuttingDownException(拒绝而非排队);对 JVM 钩子已先触发的情况幂等跳过 |
| ③ 等在途收尾 | manager.awaitTermination(Duration.ofSeconds(30)) | 返回 true=在途 call 全部正常收尾(各自已落盘);false=超时,框架中断 + ShutdownStateSaver 落盘(此时连接池仍活,落盘必达——这正是本类存在的理由) |
| ④ 排空流水账 | conversationLogExecutor.shutdown() + awaitTermination(15s) | 限时等待在途 insertWithRetry(含重试退避)落库;15s 内没排空则告警放弃(已接受的可靠性边界) |
两个超时参数就是启动日志里那行的来历(start()):
优雅停机生命周期已就绪(agentTimeout=30s, logDrain=15s)
agentTimeout=30s(agent.shutdown.agent-timeout-seconds):等一轮正常推理收尾够用,又不至于挂住发布流程;logDrain=15s(agent.shutdown.log-drain-seconds):覆盖流水账有限次重试的退避总时长。
真实停机时序(logs/app.log.195939.bak)恰好印证了这套先后:
GracefulShutdownLifecycle - Agent 在途请求处理完毕(正常完成=true)
o.s.boot.tomcat.GracefulShutdown - Commencing graceful shutdown ...
o.a.c.c.tomcat-shutdown - Graceful shutdown complete
一个容易忽略的边界(类注释点明):LRU 缓存里的 Agent 实例无需额外 flush——它们的 AgentState 每次 call 结束已实时落盘;且 performGracefulShutdown() 对非 RUNNING 态幂等,JVM 钩子与本类不会重复执行。
10.4 PermissionGatewayCleaner:它其实不是停机组件
这是本篇要纠正的一个常见误解。虽然它和停机类同住 job 包、名字也带「Cleaner」,但 PermissionGatewayCleaner 跟优雅停机毫无关系——它是一个 @Scheduled(fixedRate = 60_000L) 的运行期定时任务:
@Scheduled(fixedRate = 60_000L)
public void cleanExpired() {
permissionGateway.evictExpired(TIMEOUT_MILLIS); // TIMEOUT_MILLIS = 5 分钟
}
它要解决的是内存泄漏,不是停机收敛:PermissionGateway 用内存表 pendingMap 登记「已发出确认请求、但用户尚未应答」的挂起项。如果用户看到确认弹窗后一直不点、或前端断连、直接关页面,这些挂起项会永久驻留内存。于是每 60s 扫一次,把登记时间超过 5 分钟的直接摘除:
public void evictExpired(long timeoutMillis) {
long now = System.currentTimeMillis();
pendingMap.entrySet().removeIf(e -> now - e.getValue().createdAt > timeoutMillis);
}
注意它的语义边界:evictExpired 只清内存挂起表,不触碰 SDK 侧已持久化的 pending 态。这是它跟另一个方法的关键分野。
10.5 挂起 HITL 的三条清理路径:evict / cancel / drainDeny
既然讲到 HITL 挂起项的清理,就把 PermissionGateway 上三个易混方法一次辨析清楚,它们分别对应三种触发场景:
| 方法 | 触发场景 | 是否影响 SDK 持久态 | 语义 |
|---|---|---|---|
evictExpired | 定时任务(60s/次,超时 5min) | 否,仅清内存表 | 用户长期不应答的兜底回收,防内存泄漏 |
cancelPending | 会话取消/断连兜底 | 否,仅按 requestId 删内存 | 单纯撤销登记,不给 Agent 收口 |
drainDenyResults | 用户点「停止」(第九篇 9.2 /chat/cancel) | 是,返回 DENY 结果供二次 streamEvents 收口 | 把该会话所有挂起工具转成 confirmed=false,让 Agent 以「用户拒绝」语义正常收尾,清掉持久化 ASKING 态 |
第三条正是第九篇 9.2 提到的那个 Bug 修复的服务端核心:用户在 Agent 挂起等确认时点「停止」,若只切前端读取而不给 SDK 一个应答,持久化的 ASKING 态会永久残留,该会话下次发消息就校验失败卡死。drainDenyResults 按 (userId, sessionId) 维度把挂起工具一次性转 DENY,由 AgentService 携带该结果二次 streamEvents,把 ASKING 态清成正常态——所以它跟 cancelPending 的本质区别是:前者给 Agent收口,后者只删登记。
10.6 小结:停机是编排,清理是治理,两者不要混为一谈
| 关注点 | GracefulShutdownLifecycle | PermissionGatewayCleaner |
|---|---|---|
| 何时触发 | 应用停机(SmartLifecycle.stop) | 运行期每 60s(@Scheduled) |
| 解决什么 | 在途 call 落盘 + 流水账排空的有序收敛 | 挂起 HITL 内存表的泄漏回收 |
| 依赖方向 | client→infra(注入流水账执行器) | client→domain(委托 PermissionGateway) |
| 核心难点 | 抢在连接池关闭之前落盘(时序缺口) | 只清内存、不误伤 SDK 持久态 |
一句话收束:优雅停机的本质是「争取时间」——在进程真正消失前,把在途状态落盘、把异步事实落库;而它能成立的前提,是应用层比框架 JVM 钩子更早介入,卡在连接池还活着的那个窗口里。 至于挂起 HITL 的收敛,则被拆成三条各司其职的路径:定时 evict 防泄漏、cancel 撤登记、drainDeny 给 Agent 一个体面的收口。
(全文完)