从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南

0 阅读59分钟

从零构建一个生产级记忆型 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 源码地址

xingyun.jd.com/codingRoot/…

1.0.2 参考文档

java.agentscope.io/v2/zh/docs/…

1.0.3 Agent效果演示

image.png

image.png

image.png

image.png

image.png

image.png

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(Java record)。不依赖任何 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 一次对话的生命周期(先建立直觉)

为了让后续章节有整体坐标,先粗略走一遍「用户发一条消息」会发生什么(细节在第四篇展开):

  1. 前端 POST /agent/chat/stream,带 sessionIdrequestIdmessage
  2. AgentController 从 JWT 上下文取 userId绝不信任请求体)。
  3. 幂等闸口(userId, sessionId, requestId) 三元组原子判重——首见放行,重复则重放或返回 409。
  4. 建立永不超时的 SSE 连接,桥接成技术中立的 StreamSink,登记进「保活表」。
  5. 委托 AgentService 驱动 AgentScope 的 ReActAgent 流式推理,逐 token 通过 StreamSink 下发。
  6. 若命中危险工具 → 发 ask 事件挂起,等用户 permission/reply 后恢复续传(HITL)。
  7. 流结束 → 落账到 conversation_log(虚拟线程异步直写)、幂等记录置 DONE
  8. 高发问题会被定时任务离线提炼成「经验」,回填到全局共享记忆,供后续对话召回。

第二篇 知识地基: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 承载,落 MySQL agentscope_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 的依赖声明可以确证依赖方向:

因此依赖关系是:

注意关键一环:infra → domain。技术实现层反过来依赖领域层,这正是「依赖倒置」的落地——domain 定义接口,infra 实现接口。domain 自身干净,不含任何 Web/数据库依赖。

3.3 端口—适配器:domain 定义接口,infra 提供实现

domain 层 com.joker.domain.agentScope2 包下定义了一组「端口」(纯接口),职责清晰:

端口接口(domain)职责适配器实现(infra)
ConversationLogService对话流水账读写ConversationLogServiceImpl
ExperienceMemoryService经验记忆检索/落库ExperienceMemoryServiceImpl
RequestIdempotencyService请求级幂等四态RequestIdempotencyServiceImpl
SessionMetaService会话元数据 CRUDSessionMetaServiceImpl
SessionSkillService会话级 Skill 选择态SessionSkillServiceImpl
SkillMetaServiceSkill 元数据SkillMetaServiceImpl
PermissionGatewayHITL 权限网关(domain 内含逻辑,见第四篇)
StreamSink技术中立的流式下沉SseStreamSink(client 层桥接)

以幂等端口为例,RequestIdempotencyServiceImpl 头部注释明确写道:实现 domain 端口 RequestIdempotencyService,持有 RequestIdempotencyMapper实体不出 infra 边界,对外只暴露嵌套视图 GateResult / IdempotencyView。这正是端口—适配器的教科书式落地:领域只认接口与只读视图,MyBatis 实体被严格挡在 infra 内部。

3.4 只读视图(DTO record):领域不泄露技术实体

domain 层用一批 Java record(如 GateResultIdempotencyViewSkillViewSessionViewMessageViewHotQuestionConversationLogRowSkillSelection)作为「领域只读视图」。价值:

  • 隔离: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)复用。无状态可共享的部分仍是单例——见 DeepSeekModelConfigOpenAIChatModel + 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本轮流的会话/请求标识sessionIdrequestId
skill本轮参考的沉淀 SOP(透明化)skills[](name/source/hitCount)
token增量文本(打字机)文本片段
tool工具调用轨迹工具名/入参/结果
askHITL 权限确认请求askId、待确认工具描述
command_confirm命令审批请求待执行命令
done本轮终结信号流式不带 text;重放带 replay=true
error出错codemessage

学习要点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,顺序严谨:

  1. 入口自愈:若检测到会话残留 HITL 挂起态,先补发 DENY 走一次 streamEvents 收口(否则 SDK 里 ASKING 态会污染新对话)。
  2. sessionMetaService.touch 刷新活跃时间。
  3. 先落 USER 提问账,并计算语义指纹 fingerprint(同一算法贯穿经验闭环)。
  4. 发 session 事件。
  5. Skill 混合式渐进披露注入SkillInjectionService.resolveAndRender):pinned 完整正文直注 + 其余目录段;任何异常降级为空注入,绝不打断主流。
  6. 真正送模型的文本 = Skill 注入段 + 用户原文(但落账/指纹仍用原文)。
  7. 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 触发):

  1. permissionGateway.completeChecked 校验归属并取回 List<ConfirmResult>
  2. 取回同一个 sink(HITL 挂起期间前端保活了这条 SSE 连接)。
  3. 取出 suspendedReplies 里挂起前的部分回复作为 prelude
  4. 构造一条 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 权限确认命令审批
触发RequireUserConfirmEventexecute_shell_command 工具执行前
对 Agent 流挂起整条流(SDK 持久化 ASKING 态)不挂起流,仅阻塞工具线程
SSE 事件askcommand_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

  1. tryOccupyINSERT IGNORE ... VALUES(..., 'PROCESSING', NOW(3))——返 1=首见占位成功,返 0=已存在。单语句原子判重+占位,无竞态
  2. findByTriple:按 userId:sessionId:requestId 三元组查当前状态。
  3. takeoverLease(CAS 抢占):UPDATE SET PROCESSING+新租约 WHERE 三元组 AND ((status='PROCESSING' AND lease_expire<NOW(3)) OR status='FAILED')——WHERE 条件即 CAS 语义,只有「租约过期的僵尸」或「失败可重跑」才被抢占。
  4. finishUPDATE SET status/finish_reason/result_ref WHERE 三元组 AND status='PROCESSING'——终态不可二次覆盖(双保险)。

租约持有者 = 随机 UUID 的 instanceIdlease-seconds 默认 120s(僵尸自动可被抢占)。

Web 层四态分流AgentController.chatStream):

  • FIRST_SEEN → 放行走正常流程。
  • DONE → 新建 SseEmitterreplaySink 补发单条 donereplay=true)后 complete,HTTP 200 重放。
  • PROCESSING → 抛 BizException 409 → 前端转 /chat/status 轮询。
  • CANCELLED → 409。

关键约束(被 SSE 特性倒逼) :幂等判定必须在建 SseEmitter 之前完成。因为 SSE 一旦创建即返回 HTTP 200,之后无法再改状态码——想返 409 就来不及了。

Bug2(同步异常崩 500)chatStream 内 SDK 的 FluxCreate 订阅体是同步执行的,subscribeOn 拦不住构造阶段的同步异常,异常冒泡到 Web 层时 text/event-stream 没有对应 converter 直接崩 500。修复:入口 try-catch 把同步异常转成 SSE error 事件 + markFailed + complete,不冒泡。

4.7 鉴权与并发安全:JwtInterceptor 的两条防线

JwtInterceptor 是所有受保护接口的入口守卫:

  • preHandle:校验 Bearer token → 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 侧用 AtomicBoolean CAS 保证幂等;MCP 客户端 Bean destroyMethod=close 防连接泄漏。


第五篇 根系:数据持久化(短期状态 / 业务表 / Cache-Aside / 月分区)

记忆型 Agent 的价值全在「记得住」。本篇讲清楚数据存在哪、怎么存、怎么读得快、怎么不撑爆库。

5.1 理论:Agent 有几种「记忆」,分别存哪

第二篇已从概念上区分了记忆分层,这里落到存储:

记忆类型存什么存哪生命周期
短期状态Agent 当前会话的完整对话状态(含 ASKING 挂起态)官方 agentscope_sessions 表(MySQL 真相源)+ Redis 热缓存随会话
对话流水账每轮 USER/ASSISTANT 原文 + 工具轨迹 + 指纹conversation_log(月分区)长期,超窗归档
经验记忆高发问题提炼的 SOPexperience_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):

  1. conversation_log:对话流水账,按月 RANGE 分区(详见 5.4)。
  2. request_idempotency:请求幂等四态 + 租约 + 结果重放(独立非分区表,uk_req 全局唯一)。
  3. experience_entry:经验条目,user_id 默认 __global__ 全局共享,trouble_steps JSON 存有序 SOP。
  4. skill_meta:Skill 元数据,hit_count 记注入命中次数。
  5. session_meta:会话列表/标题/时间,idx_user_updated 支持侧边栏按更新时间倒序。
  6. session_skill:会话级 Skill 手动选择态(pinned/muted)。
  7. app_user:用户账号,password_hash 存 BCrypt 哈希绝不存明文user_id 即 JWT 的 sub。
  8. 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 负责冷数据治理,遵循「归档而非丢弃」(合规审计/离线统计仍可查):

  1. 分区滚动:定期新建下月分区(避免所有数据落进 pmax)。
  2. 冷分区归档:把超保留窗口(默认 90 天,可配)的冷分区整段 INSERT ... SELECT 搬入 conversation_log_archive
  3. 秒级丢弃ALTER TABLE ... DROP PARTITION 秒级丢弃在线表冷分区。
  4. 两表清理:清理过期的 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 过滤)。

闭环各环节

  1. 落账(第五篇) :每轮 USER/ASSISTANT 带同一指纹落 conversation_log,同指纹的 USER/ASSISTANT 行配对成「一轮问答」语料。

  2. 聚合 + 提炼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 不向调度线程抛——一个指纹失败不中断整批
  3. 回填applyDistilled):

    • 落 experience_entryuser_id = __global__ 全局共享(所有用户受益)。
    • 同时把 SOP 用 SkillFileStore 固化为一个 Skill 文件 + skill_meta 索引——从此该 SOP 进入 6.2 的渐进披露体系被复用。
    • 固化失败不回滚经验(applyDistilled 已成功),仅记 warn 下轮重试覆盖。

架构合法性:闭环触发端(cron 编排)在 client 层,核心业务规则(applyDistilled)在 domain 层,依赖方向 client → domain 合法。

6.4 全篇小结:一次对话如何贯穿所有能力

把六篇串起来,一次典型对话的完整旅程:

  1. 前端带 JWT 请求 /agent/chat/stream第四篇 SSE + 第四篇 JWT)。
  2. 幂等闸口判定 FIRST_SEEN 放行(第四篇 幂等四态)。
  3. domain 落 USER 账、算指纹(第五篇 分区表 + 第六篇 指纹)。
  4. Skill 混合式渐进披露注入(第六篇)。
  5. per-session Agent 跑 ReAct 循环,token 经 SSE 打字机下发(第三篇 方案 A + 第二篇 ReAct)。
  6. 命中危险工具 → HITL 挂起 / 命令审批(第四篇)。
  7. 完成 → 落 ASSISTANT 账 + markDone + 补发 done(第四篇 + 第五篇)。
  8. 凌晨 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 主应用?  三个现实原因:

  1. 技术栈就近:查 git 用系统 git 命令最省事、查京东内网日志平台需要浏览器 cookie,这些用 Python 脚本(fastmcp 库)几行就能封装,塞进 Java 反而别扭。
  2. 权限隔离:git-mcp 可以在协议层强制「只读白名单」,把危险命令挡在主应用之外——即便模型「幻觉」出一个 git push,也过不了 server 的参数校验。
  3. 可替换性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.pylog_mcp_server.py
fastmcpfastmcp
传输streamable-http /mcpstreamable-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_codegit 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_remotegit 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=trueagent.mcp.git.url=http://127.0.0.1:9101/mcpagent.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-mcplog-mcp
「最新」怎么来sync_remote(git fetch)刷新共享 clone 的 origin/uat每次实时查平台,不缓存
隔离/共享全会话共享同一份权威 clone(代码是全局事实)全会话共享同一日志平台入口
安全只读白名单,对远端零写入凭证只落本地文件,绝不进对话
环境一致性锁定 origin/uat,与日志环境对齐抓包绑定 uat 分组,与代码环境对齐

至此,「每个诊断会话如何读到最新全量代码」的完整答案是:代码事实由一份共享的真实 clone 承载,sync_remote 通过 git fetch(对远端零写入)把它刷新到远端最新,所有会话查询默认锁定 origin/uat 从而一致地读到这份最新全量代码;会话隔离留给主应用侧的对话状态,代码这类全局客观事实则共享一份权威源。


第八篇 深潜:短期状态的「重建」与两级缓存的「一致性」

第五篇已经讲清了短期状态存储的结构(CachedAgentStateStore = Redis 热缓存 + MySQL 真相源)与失忆自愈的定位。本篇是对两个最容易被问倒的细节的正面回答,也是把附录待办 B、C 落到源码的一次深潜:

  • BStateRebuildService 回放重建时,究竟读哪些字段、tool_trace 到底参不参与、如何映射回 SDK 内部状态?
  • CCachedAgentStateStore 双写时 MySQL 与 Redis 的失败如何补偿、缓存的 TTL 到底是多少?

全部结论均以 StateRebuildServiceCachedAgentStateStoreAgentStateStoreConfig 三处源码为准,不作臆测。

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:

  1. 先查 Redis,命中即返回
  2. Redis 查询异常也只告警,继续往下走;
  3. 回落 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配置层未显式设置,正确性不依赖 TTLredisAgentStateStore

一句话收束:短期状态是可丢的投影,流水账是不可丢的事实;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 跳登录
停止生成不只切前端,更要通知后端收口 HITLstopGenerating 两步: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 服务被强杀,会同时踩中三颗雷:

  1. 在途 Agent call 的状态未落盘:框架 AgentBase 在每次 call 结束时才 stateStore.save(...)。一次多轮 ReAct 推理可能耗时数秒到数十秒,中途被杀,这一轮的 AgentState 投影就丢了——下次进来从上一个检查点重放,用户看到「刚说的话它忘了」。
  2. 在途 SSE流被硬断:浏览器端的 reader 收到的是半截事件流,前端状态机悬在「生成中」。
  3. 异步流水账未落库:对话流水账走虚拟线程异步直写 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_PHASEInteger.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=30sagent.shutdown.agent-timeout-seconds):等一轮正常推理收尾够用,又不至于挂住发布流程;
  • logDrain=15sagent.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 小结:停机是编排,清理是治理,两者不要混为一谈

关注点GracefulShutdownLifecyclePermissionGatewayCleaner
何时触发应用停机(SmartLifecycle.stop运行期每 60s(@Scheduled
解决什么在途 call 落盘 + 流水账排空的有序收敛挂起 HITL 内存表的泄漏回收
依赖方向client→infra(注入流水账执行器)client→domain(委托 PermissionGateway
核心难点抢在连接池关闭之前落盘(时序缺口)只清内存、不误伤 SDK 持久态

一句话收束:优雅停机的本质是「争取时间」——在进程真正消失前,把在途状态落盘、把异步事实落库;而它能成立的前提,是应用层比框架 JVM 钩子更早介入,卡在连接池还活着的那个窗口里。  至于挂起 HITL 的收敛,则被拆成三条各司其职的路径:定时 evict 防泄漏、cancel 撤登记、drainDeny 给 Agent 一个体面的收口。


(全文完)