您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南
jd****
2026-09-17
IP归属:北京
19浏览
# 从零构建一个生产级记忆型 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 源码地址 http://xingyun.jd.com/codingRoot/LogisticsOptimizationJava/agentScope2/ ### 1.0.2 参考文档 https://java.agentscope.io/v2/zh/docs/index.html ### 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`(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`,带 `sessionId`、`requestId`、`message`。 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` 的依赖声明可以确证依赖方向: - [`starter/pom.xml`](starter/pom.xml:17) 依赖 `client` - [`client/pom.xml`](client/pom.xml:63) 依赖 `infra`,[`client/pom.xml`](client/pom.xml:68) 依赖 `domain` - [`infra/pom.xml`](infra/pom.xml:19) 依赖 `domain` - [`domain/pom.xml`](domain/pom.xml:10) **不依赖任何内部模块** 因此依赖关系是:  注意关键一环:**`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/src/main/java/com/joker/domain/agentScope2/StreamSink.java:1): - domain 的 `AgentService` 只面向 `StreamSink` 编程:`send(name, payload)` / `complete()` / `error()` / `onTerminate(...)`。 - 真正的 `SseEmitter` 与 JSON 序列化在 client 层的 [`SseStreamSink`](client/src/main/java/com/joker/client/app/SseStreamSink.java:1) 里实现——把 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`](infra/src/main/java/com/joker/infra/common/DeepSeekAgentConfig.java:1) 被刻意做成**不产出任何 Bean 的废弃占位类**(`final class` + 私有构造),注释明确记录「移除单例 ReActAgent Bean」的原因。 **解法(方案 A)**:改为**每会话独立构建 `ReActAgent` + `Toolkit`**,用 LRU 缓存(容量 256)复用。无状态可共享的部分仍是单例——见 [`DeepSeekModelConfig`](infra/src/main/java/com/joker/infra/common/DeepSeekModelConfig.java:1)(`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`](client/src/main/java/com/joker/client/app/AgentController.java:152) 承载。 ## 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`](client/src/main/java/com/joker/client/app/AgentController.java:152): - `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`](domain/src/main/java/com/joker/domain/agentScope2/AgentService.java:291),顺序严谨: 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`](domain/src/main/java/com/joker/domain/agentScope2/AgentService.java:470): - `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`](domain/src/main/java/com/joker/domain/agentScope2/AgentService.java:405)(由 `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 权限确认 | 命令审批 | | --- | --- | --- | | 触发 | `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`](infra/src/main/java/com/joker/infra/repo/RequestIdempotencyMapper.java:1) 的**四条原子 SQL**: 1. `tryOccupy`:`INSERT 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. `finish`:`UPDATE SET status/finish_reason/result_ref WHERE 三元组 AND status='PROCESSING'`——终态不可二次覆盖(双保险)。 租约持有者 = 随机 UUID 的 `instanceId`,`lease-seconds` 默认 120s(僵尸自动可被抢占)。 **Web 层四态分流**([`AgentController.chatStream`](client/src/main/java/com/joker/client/app/AgentController.java:152)): - `FIRST_SEEN` → 放行走正常流程。 - `DONE` → 新建 `SseEmitter`,`replaySink` 补发单条 `done`(`replay=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`](client/src/main/java/com/joker/client/auth/JwtInterceptor.java:1) 是所有受保护接口的入口守卫: - **preHandle**:校验 `Bearer` token → `jwtUtil.parseUserId(sub)` → `UserContext.set(userId)`(ThreadLocal)放行。缺失/验签失败统一返 **401**,不泄露具体原因。 - **afterCompletion**:**无条件 `UserContext.clear()`**。 > **学习要点(并发安全铁律)**:`UserContext` 是 ThreadLocal,Web 容器线程池会**复用线程**。若不在请求结束时 clear,下一个复用该线程的请求会读到上一个用户的 userId——**越权串号**。这就是为什么 afterCompletion 必须无条件清理。 ## 4.8 取消与清理:让「停止」和「删会话」不留幽灵 - **主动停止** [`cancelStream`](domain/src/main/java/com/joker/domain/agentScope2/AgentService.java:712):取消活跃流 + **补发 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`(月分区) | 长期,超窗归档 | | **经验记忆** | 高发问题提炼的 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`](starter/src/main/resources/db/schema.sql:1)): 1. [`conversation_log`](starter/src/main/resources/db/schema.sql:9):对话流水账,**按月 RANGE 分区**(详见 5.4)。 2. [`request_idempotency`](starter/src/main/resources/db/schema.sql:32):请求幂等四态 + 租约 + 结果重放(独立非分区表,`uk_req` 全局唯一)。 3. [`experience_entry`](starter/src/main/resources/db/schema.sql:47):经验条目,`user_id` 默认 `__global__` 全局共享,`trouble_steps` JSON 存有序 SOP。 4. [`skill_meta`](starter/src/main/resources/db/schema.sql:63):Skill 元数据,`hit_count` 记注入命中次数。 5. [`session_meta`](starter/src/main/resources/db/schema.sql:77):会话列表/标题/时间,`idx_user_updated` 支持侧边栏按更新时间倒序。 6. [`session_skill`](starter/src/main/resources/db/schema.sql:90):会话级 Skill 手动选择态(pinned/muted)。 7. [`app_user`](starter/src/main/resources/db/schema.sql:102):用户账号,`password_hash` 存 **BCrypt 哈希绝不存明文**,`user_id` 即 JWT 的 sub。 8. [`conversation_log_archive`](starter/src/main/resources/db/schema.sql:119):流水账归档表,与主表同构但不分区(详见 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`](domain/src/main/java/com/joker/domain/agentScope2/SkillInjectionService.java:60) 采用**混合式**策略,注入文本分两段: - **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`](client/src/main/java/com/joker/client/job/ExperienceDistillJob.java:1),纯 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`](client/src/main/java/com/joker/client/job/ExperienceDistillJob.java:233)): - 落 `experience_entry`,`user_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`](infra/src/main/java/com/joker/infra/common/DiagnosisAgentConfig.java:38)。它在 `agent.mcp.enabled=true` 时装配 `gitMcpClient` / `logMcpClient` 两个**容器单例** Bean,`destroyMethod="close"` 保证容器销毁时释放连接(防泄漏),`buildSync()` 在应用启动期一次性建连。这两个 client 随后被直接挂到**主 Agent** 的 Toolkit(见 [`SessionToolkitManager`](domain/src/main/java/com/joker/domain/agentScope2/workspace/SessionToolkitManager.java:79)),**不再走诊断子 Agent**(第三篇 3.7 已讲过取消子 Agent 的方案 A 与死循环根因)。 ## 7.2 两个 server 一览 | 维度 | git-mcp(:9101) | log-mcp(:9102) | | --- | --- | --- | | 源码 | [`git_mcp_server.py`](mock-mcp/git_mcp_server.py:1) | [`log_mcp_server.py`](mock-mcp/log_mcp_server.py:1) | | 库 | `fastmcp` | `fastmcp` | | 传输 | streamable-http `/mcp` | streamable-http `/mcp` | | 数据源 | 本机 clone 的真实 git 仓库(京东 coding) | 京东 DongMonitor 日志平台 HTTP API | | 认证 | 本机 SSH key(server 跑在本机,非沙箱) | 浏览器抓包 cookie(存本地文件,绝不进对话) | | 安全红线 | 只读白名单,对远端零写入 | 只读查询,凭证只落本地文件 | | 工具数 | 11 个 | 4 个 | | 启动 | [`start.sh`](mock-mcp/start.sh:1)(可 `--daemon` 后台) | 同上,一键同拉两个 | ## 7.3 git-mcp:只读白名单 + 环境锁定 ### 7.3.1 只读红线是怎么落地的 git-mcp 的核心安全机制是 **白名单模式**:只有落在 `_READONLY_WHITELIST` 里的纯读子命令才放行,白名单外一律拒绝(见 [`git_mcp_server.py`](mock-mcp/git_mcp_server.py:60) 的集合定义与 [`_run_git_raw`](mock-mcp/git_mcp_server.py:82) 的校验)。白名单包含 `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`](mock-mcp/git_mcp_server.py:45))。原因很实在:**本地只在 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`](mock-mcp/git_mcp_server.py:295)):它专门用 `_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`](mock-mcp/git_mcp_server.py:150))。它做的事: ``` 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`](mock-mcp/log_mcp_server.py:9) 的模块注释与 [`_load_curl`](mock-mcp/log_mcp_server.py:44)): - **每次工具调用都重新读取 `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`](mock-mcp/start.sh:1) 同时拉起两个 server;用 `.venv` 虚拟环境自举(Homebrew Python 受 PEP 668 管控不能直接 pip install),并做端口占用自检;`--daemon` 后台运行 + 端口就绪等待,`--stop` 停止。 - **主应用侧配置**:[`application.properties`](starter/src/main/resources/application.properties:54) 里 `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`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:57)、[`CachedAgentStateStore`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:29)、[`AgentStateStoreConfig`](infra/src/main/java/com/joker/infra/common/AgentStateStoreConfig.java:35) 三处源码为准,不作臆测。 ## 8.1 理论:为什么需要「重建」——事件溯源思想的一次落地 有状态服务永远绕不开一个问题:**承载状态的存储损坏了、被误清了、或过期淘汰了,用户再进来怎么办?** 业界的成熟答案是**事件溯源(Event Sourcing)**:不把「当前状态」当作唯一真相,而是把「导致状态变化的一连串事件」持久化下来;当状态丢失时,从事件流**重放(replay)**即可重建出等价的当前状态。 把这套思想映射到本项目: | 事件溯源概念 | 本项目对应物 | | --- | --- | | 事件流(append-only 的历史事实) | `conversation_log`:每轮 USER/ASSISTANT **双行落账**,只增不改 | | 当前状态(可重建、可丢弃的投影) | Agent 短期状态 `AgentState`(存于 agentscope_sessions + Redis) | | 重放引擎 | [`StateRebuildService`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:57) | | 重放触发条件 | 状态存储 miss(损坏/误清/TTL 淘汰后续聊) | 关键洞察:**短期状态是「可丢的投影」,流水账才是「不可丢的事实」**。这正是文档 13.3 把 `conversation_log` 称作「最后一道防线」、把重建称作「双保险」的原因——真相源即便整个丢了,只要流水账还在,历史对话就能续得上。 ## 8.2 回放的精确口径(回答附录 B) ### 8.2.1 触发时机:只在 cache-miss 且有历史时才重建 [`rebuildIfMissing`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:104) 的第一步就是 `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`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:36) 的类注释里写得很明确: - **只取 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`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:44) 类注释原文:*「toolTrace 不回放——它是给前端展开的轨迹 JSON,非对话内容。」* 回放只用到流水账行的 `role` 与 `content` 两个字段(见 [`toMsg`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:160)),`tool_trace` 列压根没被读取。 理由清晰:`tool_trace` 是**给前端 `replayTrace` 做历史轨迹回显**用的(重进会话时按序重现 📘/🔧 图标),它属于「展示层的附加信息」,不是「模型上下文的一部分」。重建的目标是让模型**能接着聊**,而不是让前端**能重画轨迹**——这是两件事,用两份数据。 ### 8.2.4 如何映射回 SDK 内部状态:预写 agent_state,让框架自动加载 重建产物是一个框架的 `AgentState`,组装逻辑见 [`buildAgentState`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:150): ```java 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`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:63))。框架 2.0.1 的 `ReActAgent` 每轮调用时会惰性执行 `loadOrCreateAgentStateForSlot`,按 `stateStore.get(userId, sessionId, "agent_state", AgentState.class)` 读取——所以我们只要在 Agent 首次使用**之前**把状态预写进去,**首轮 call 就会自动加载**,无需任何反射干预。 > ⚠️ 一个升级风险:`"agent_state"` 是框架内部硬编码字面量,官方未提供公开常量,本项目以同名常量对齐(源码注释已标注「升级框架版本时需复核」)。 ### 8.2.5 容错红线:重建失败绝不阻断主流程 [`rebuildIfMissing`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:104) 整个方法体裹在 try/catch 里,任何异常(流水账查询失败、状态写入失败等)**只记 warn 不抛出**——退化为「全新会话」体验,绝不阻断用户的主对话。这正是「双保险兜底」应有的姿态:兜底本身失败,最坏也只是回到没有兜底的状态,不能反过来把主流程拖垮。 并发安全上也做了考量:`save` 是全量替换语义,即便两个线程同时命中 miss 一起重建,也只是重复写同一份内容,最终一致。 ## 8.3 两级缓存的失效与一致性(回答附录 C) ### 8.3.1 一致性总原则:MySQL 真相源,Redis 只加速 [`CachedAgentStateStore`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:29) 是一个**装饰器**,对外暴露单一 `AgentStateStore` 契约,内部组合 MySQL(真相源)+ Redis(热缓存)。类注释把策略写得很干脆:**MySQL 为 source of truth,Redis 仅加速**。 这条原则决定了所有读写的失败补偿方向——**任何 Redis 操作失败都只告警、绝不影响正确性,因为真相永远在 MySQL**。 ### 8.3.2 写:先落真相源,再写缓存(缓存失败不阻断) [`save`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:50) 的顺序是**先 MySQL 后 Redis**: ```java 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`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:82) 是标准 Cache-Aside: 1. 先查 Redis,**命中即返回**; 2. Redis 查询异常也只告警,继续往下走; 3. 回落 MySQL,若查到则**回填 Redis**(回填失败仅告警),下次即可命中。 `getList` 逻辑完全一致(命中判定为「非空列表」)。这样即使 Redis 整个挂了,读路径也只是**每次都走 MySQL**——慢一点,但永远正确。 ### 8.3.4 exists / delete / listSessionIds 的口径 - [`exists`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:135):Redis 命中即短路返回 `true`,否则以 MySQL 为准(这也是 8.2.1 中重建判定「零成本短路」的底气); - [`delete`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:153)(整会话 / 指定 key 两个重载):**先删 MySQL 再删 Redis**,Redis 删失败仅告警——即便缓存残留脏数据,下次读也会因 MySQL 已删而返回空,不会读到幽灵; - [`listSessionIds`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:180):直接以 MySQL 为准,Redis 不做会话枚举缓存; - [`close`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:186):先关 Redis(失败仅告警)再关 MySQL,**保证真相源资源必然释放**。 ### 8.3.5 关于「TTL 具体值」——诚实的核对结论 附录 C 原本想核对「缓存 TTL 具体值」。逐行核对 [`AgentStateStoreConfig`](infra/src/main/java/com/joker/infra/common/AgentStateStoreConfig.java:88) 的实际装配后,结论需要**如实修正**: ```java 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`](infra/src/main/java/com/joker/infra/common/AgentStateStoreConfig.java:70)),这是**连接超时**、不是 key TTL,两者不可混为一谈。 > 📌 修正说明:先前把「Redis `sess:` 前缀 TTL 自动淘汰」当作既定事实是不够严谨的——本项目配置层并未显式设 TTL。真正稳固的一致性保证不来自 TTL,而来自**「MySQL 真相源 + 缓存 miss 必回落 + 被清会话由 `StateRebuildService` 回放自愈」**这一整套机制:即便某条缓存永不过期或提前消失,正确性都不受影响。若确需给热缓存加显式 TTL,应在 `RedisAgentStateStore.builder()` 处补充对应参数,此处留作可选优化项。 ## 8.4 小结:投影可丢,事实不丢 | 维度 | 结论 | 源码依据 | | --- | --- | --- | | 重建触发 | 仅 cache-miss 且流水账有历史 | [`rebuildIfMissing`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:104) | | 回放语料 | 只取 USER + `stop` 的 ASSISTANT,截尾 200 | 类注释 + `findReplayableMessages` | | tool_trace | **不参与重建**,仅前端回显用 | [`toMsg`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:160) 只读 role/content | | 状态映射 | 预写 `agent_state`,框架首轮 call 自动加载 | [`AGENT_STATE_KEY`](domain/src/main/java/com/joker/domain/agentScope2/workspace/StateRebuildService.java:63) | | 写一致性 | 先 MySQL(抛错)后 Redis(仅告警) | [`save`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:50) | | 读一致性 | Cache-Aside:命中返回,miss 回落并回填 | [`get`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:82) | | 删一致性 | 先删 MySQL 后删 Redis,残留不致读脏 | [`delete`](infra/src/main/java/com/joker/infra/common/CachedAgentStateStore.java:153) | | Redis TTL | 配置层**未显式设置**,正确性不依赖 TTL | [`redisAgentStateStore`](infra/src/main/java/com/joker/infra/common/AgentStateStoreConfig.java:88) | 一句话收束:**短期状态是可丢的投影,流水账是不可丢的事实;Redis 快而不可靠、MySQL 慢而权威,一切失败补偿都朝真相源收敛,一切失忆都由回放自愈。** --- # 第九篇 前端四个「不得不讲」的交互细节 > 全文按用户要求对前端「一笔带过」,把笔墨留给后端与持久化。但有四个交互细节,它们**不是纯 UI 问题,而是分布式契约在浏览器端的另一半落地**——不讲清楚,后端的 SSE / HITL / 幂等 / 鉴权就只有半张图。本篇专门补白附录 E 列出的四点,全部以 [`index.html`](client/src/main/resources/templates/index.html:1)(对话主界面)源码为据。 > > 前置说明:本项目前端是「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()`](client/src/main/resources/templates/index.html:389) 收口: ```javascript 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'; }`](client/src/main/resources/templates/index.html:367),无 token 直接挡在门外(与 ViewController 的服务端跳转形成双保险)。 这与后端 `JwtInterceptor` 构成完整闭环:**后端负责判定失效,前端负责统一善后**。 ## 9.2 停止生成:不只是「切断前端」,更要「通知后端收口」 这是四个细节里**最容易做错、也最能体现分布式思维**的一个。 用户点「停止」时,天真的做法是「把浏览器端的 SSE 读取取消掉」就完事。但本项目的 [`stopGenerating`](client/src/main/resources/templates/index.html:1165) 做了**两步**: ```javascript 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`](client/src/main/resources/templates/index.html:1153) 管理发送/停止按钮互斥,并做了终态兜底:若停止时状态条仍停在「进行中」(没收到 `done`),收敛为静态「⏹ 本轮已结束」以停掉脉冲动画。生成中还禁止切换会话([第467行](client/src/main/resources/templates/index.html:467)),避免流串到别的会话。 ## 9.3 历史回显:让「重进会话」和「实时对话」看起来一模一样 第八篇讲过 `tool_trace` 不参与状态重建、只供前端回显。这里就是它的消费端。 打开会话 [`openSession`](client/src/main/resources/templates/index.html:501) 拉 `GET /agent/session/{id}/messages`,返回 `[{role, content, toolTrace, createdAt}]`,逐条回显时对助手消息做了关键处理: ```javascript if (m.toolTrace) { replayTrace(m.toolTrace); } // 先原样重现过程轨迹 setBubbleContent(bubble, m.content || '', 'assistant'); // 再显示回复正文 ``` [`replayTrace`](client/src/main/resources/templates/index.html:943) 把落库的 `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`](client/src/main/resources/templates/index.html:1389): ```javascript 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)`](client/src/main/resources/templates/index.html:526) 先从本地恢复,缺失则回默认空选择; - **双写后端**:同时 `PUT /session/{id}/skills` 落库,使选择态跨设备/跨浏览器也能一致(本地只是快取); - **发送时上送**:`sendMessage` 组装 body 时带上 `pinnedSkillIds/mutedSkillIds`([第999行](client/src/main/resources/templates/index.html:999)),交给后端 `SkillInjectionService` 做混合式渐进披露(呼应第六篇)。 ## 9.5 小结:前端是分布式契约的另一半 | 交互细节 | 本质 | 关键实现 | | --- | --- | --- | | 全局 401 拦截 | 鉴权失效的统一善后 | [`api()`](client/src/main/resources/templates/index.html:389) 单入口注入 token + 401 跳登录 | | 停止生成 | **不只切前端,更要通知后端收口 HITL** | [`stopGenerating`](client/src/main/resources/templates/index.html:1165) 两步:cancel reader + `/chat/cancel` | | 历史回显 | `tool_trace` 的消费端,与实时版式一致 | [`replayTrace`](client/src/main/resources/templates/index.html:943) 复用实时文案 + 脏数据跳过 | | 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`](client/src/main/java/com/joker/client/job/GracefulShutdownLifecycle.java:3) **本身就注册了 JVM 的 SIGTERM 钩子**:停机时它会等在途请求、超时中断、并经 `ShutdownStateSaver` 把中断态落盘(`shutdownInterrupted=true`,下次调用自动续跑)。日志里能看到它的身影: ```text GracefulShutdownManager - Graceful shutdown initiated, 0 active request(s), timeout=infinite agentscope-jvm-shutdown-hook ... ``` 看起来框架全包了。但 [`GracefulShutdownLifecycle`](client/src/main/java/com/joker/client/job/GracefulShutdownLifecycle.java:64) 的类注释点破了一个**致命时序缺口**: > JVM 钩子在 **Spring 容器销毁之后**才执行,而 stateStore 底层的 HikariCP 连接池是 Spring 管理的 Bean——届时池已关闭,`ShutdownStateSaver` 的落盘会全部失败,等于优雅停机形同虚设。 一句话:**框架想落盘时,数据库连接池已经没了。** 应用层这个类的全部意义,就是把「等在途、中断、落盘」这套动作,从「JVM 钩子(太晚)」**提前到 Spring 的 `SmartLifecycle.stop()` 阶段(连接池仍存活)**去做。 ## 10.3 `GracefulShutdownLifecycle`:把停机塞进 Spring 生命周期的正确时刻 它实现 [`SmartLifecycle`](client/src/main/java/com/joker/client/job/GracefulShutdownLifecycle.java:59),靠一个 `phase` 值卡位: ```java @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()](client/src/main/java/com/joker/client/job/GracefulShutdownLifecycle.java:100)): | 步 | 动作 | 关键点 | | --- | --- | --- | | ① 设有限超时 | `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()](client/src/main/java/com/joker/client/job/GracefulShutdownLifecycle.java:104)): ```text 优雅停机生命周期已就绪(agentTimeout=30s, logDrain=15s) ``` - `agentTimeout=30s`(`agent.shutdown.agent-timeout-seconds`):等一轮正常推理收尾够用,又不至于挂住发布流程; - `logDrain=15s`(`agent.shutdown.log-drain-seconds`):覆盖流水账有限次重试的退避总时长。 真实停机时序(`logs/app.log.195939.bak`)恰好印证了这套先后: ```text 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`](client/src/main/java/com/joker/client/job/PermissionGatewayCleaner.java:31) 跟优雅停机**毫无关系**——它是一个 `@Scheduled(fixedRate = 60_000L)` 的**运行期定时任务**: ```java @Scheduled(fixedRate = 60_000L) public void cleanExpired() { permissionGateway.evictExpired(TIMEOUT_MILLIS); // TIMEOUT_MILLIS = 5 分钟 } ``` 它要解决的是**内存泄漏**,不是停机收敛:[`PermissionGateway`](domain/src/main/java/com/joker/domain/agentScope2/PermissionGateway.java:61) 用内存表 `pendingMap` 登记「已发出确认请求、但用户尚未应答」的挂起项。如果用户看到确认弹窗后一直不点、或前端断连、直接关页面,这些挂起项会**永久驻留内存**。于是每 60s 扫一次,把登记时间超过 5 分钟的直接摘除: ```java 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`](domain/src/main/java/com/joker/domain/agentScope2/PermissionGateway.java:169) | 定时任务(60s/次,超时 5min) | 否,仅清内存表 | 用户长期不应答的兜底回收,防内存泄漏 | | [`cancelPending`](domain/src/main/java/com/joker/domain/agentScope2/PermissionGateway.java:121) | 会话取消/断连兜底 | 否,仅按 `requestId` 删内存 | 单纯撤销登记,不给 Agent 收口 | | [`drainDenyResults`](domain/src/main/java/com/joker/domain/agentScope2/PermissionGateway.java:147) | **用户点「停止」**(第九篇 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 一个体面的收口。 --- *(全文完)*
上一篇:JDD Oxygen智能零售论坛 | 《Agentic广告营销新范式》
下一篇:数据涅槃:AI 赋能的深度加工与价值重塑
jd****
文章数
1
阅读量
19
作者其他文章
01
从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南
从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南本文既是本项目的完整技术文档,也是一份以真实工程为载体的 AI Agent 开发学习教程。全文遵循「先讲理论、再结合本项目真实代码印证」的写法,所有结论均有据可依(源自项目 md 设计文档、.joycode/memory 记忆沉淀,或 domain/infra/client 真实代码),力求面面俱到、严谨
jd****
文章数
1
阅读量
19
作者其他文章
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号