AgentScope第26式·立派服务
AgentScope第26式·渠道网关
命令行跑得再顺,也只是在给一个人服务。
[读者] 已在 main() 里跑通 Harness Agent 的 Java 后端工程师
[痛点] Agent 只在命令行活着,Web 和企业 IM 接不进来,一并发就串会话
[现在读] 第25式已完成 Agent 装配,缺的恰好是服务化与接入层这一段
[读完] 能把 HarnessAgent 注入 Spring Boot,用 Channel/HarnessGateway 承载多接入层
[旧方案] main() + 单例 Agent + System.out
|
v
[新需求] Web Chat 与企业 IM 同时接入,多用户并发、会话连续
|
v
[冲突] Agent 天然是“有状态对象”,HTTP 天然是“无状态协议”
|
v
[后果] 要么串号,要么每次重建 Agent 把 Memory 一起丢掉
我是老李,做 Java 后端十五年,前面二十多式都在命令行里养这只数字人。
到第25式为止,它已经很能干了:能推理、能调 Tool、有 Memory、有 Skill、能 Plan。但它有一个致命习惯:进程一停就失忆,同时来两个人就开始分不清谁是谁。
真实业务不会等你准备好。项目运行页 /run/{projectId} 已经排在排期表上,运营希望 Web 里能聊,客服希望企业 IM 里也能聊。
三个问题先摆在明面上:
线程安全到底靠什么保证?是给 Agent 加锁,还是让 Agent 干脆不带状态?
一个用户从第一次说话到第十次说话,这中间的“会话”到底存在哪儿?
Web Chat 和企业 IM,为什么不该各写一套对接逻辑?
选错第一个,你会得到一台并发一高就串号的数字人;选错第二个,用户会发现自己的历史记忆跑到了别人那里;选错第三个,你会在第二个接入层出现时,把整套业务逻辑重抄一遍。
01、故事
周三下午的架构评审,白板上写着两行字:上一行是“命令行 Demo 已经跑通”,下一行是“下周一要上线 Web 运行页”。
我们的数字人原先长这样:一个 main() 方法,new 出一个 HarnessAgent,配上 model 和 systemPrompt,然后 while(true) 读控制台输入。它能干活,但只服务一个人——那个坐在终端前面的人。
任务是:把这只数字人从“命令行程序”变成“长期驻留的服务”,让 Web 端 /run/{projectId} 和后续的企业 IM 都接进来,并且多人同时用不许串号。
第25式结束时,Agent 的装配已经被抽成独立配置。本章从上一式分支继续拉出本章分支:
git checkout chapter/25-xxx
git checkout -b chapter/26-spring-channel
本章要落一次提交:
git commit -m "feat(ch26): expose Spring Boot and channel gateway"
评审桌上有人提了一句:既然 Agent 是有状态的,Spring Bean 又是单例,那是不是并发一来就必然串号?这个问题问得对,但它预设了一个错误前提——有状态的是“会话”,不是“引擎”。
命令行一人用
服务化千人用
差的不止端口
是身份的边界
02、问题
旧方案失效得很安静。
main() 里那个 Agent 对象,被我们顺手当成了“整个应用唯一的大脑”。为了记住上次聊到哪儿,有人在 Agent 类外面加了一个 lastSessionId 字段,还有人写了一个 static Map<String, List<Message>> history。命令行下没人发现异常,因为只有一个用户,永远只走一条路径。
接上 HTTP 之后,问题以三种技术形态暴露出来:
第一,并发串号。两个浏览器同时请求,第二个请求可能读到第一个请求留下的会话上下文;开了线程池之后更明显,因为线程被复用,上一次请求的残留更容易被下一次读到。
第二,会话失忆。如果为了“线程安全”每次请求都 new 一个 Agent,那 Memory、Skill 装配、连接池全部重建,上一轮对话彻底丢失,数字人变成一锤子买卖。
第三,接入层写死。聊天逻辑直接写在 @PostMapping 方法体里,一旦企业 IM 要接,就得把这段逻辑复制粘贴一份,再修一遍错误处理。
这三条落到业务上就是:用户 A 看到用户 B 的回复、用户回头聊发现数字人失忆、第二个渠道上线时工期翻倍。
本篇的完成标准,我们写成三条可验证的验收:
验收一,Web 客户端可以多用户并发流式聊天;
验收二,同一个 user/session 的对话状态连续;
验收三,不同用户之间状态不串。
单例不是原罪
有状态才是坑
并发一到场
会话先翻车
03、原理
先给三个专有名词划清边界,一句话一个,不展开:
HarnessAgent 是 AgentScope Java 中承载 harness 能力的 Agent 实现,负责一次推理的完整流程;
Channel 是接入层抽象,代表“消息可以进来的一条通道”,Web Chat、企业 IM 都可以是它的实现;
HarnessGateway 是通道与 Agent 之间的关口,把接入侧的消息规范化成 harness 可处理的输入。
这三者的具体接口边界,请以固定版本源码为准:agentscope-harness/src/main/java/io/agentscope/harness/agent/gateway/channel/Channel.java 与 agentscope-harness/src/main/java/io/agentscope/harness/agent/gateway/HarnessGateway.java,以及官方文档 docs/v2/zh/docs/harness/channel.md。
反直觉判断一:真正需要线程安全的不是 Agent,而是“每会话的状态容器”。
Spring 的单例 Bean 之所以安全,前提是它不持有可变共享状态。一个 Service 只要有 private int counter 并且被并发写,它就不再安全,跟它是不是 Agent 无关。反过来,一个不持有会话字段的 HarnessAgent,被注册成单例反而是最优解:装配一次,全程复用,推理时的会话信息由参数带进去。
反直觉判断二:用 ThreadLocal 存会话上下文,是“看起来最省事”的错误答案。
ThreadLocal 在同步 Servlet 模型下也许能跑,但在异步 SSE、线程池复用、虚拟线程这几件事叠加之后,它会以两种方式报复你:线程复用时残留旧值导致串号,或者异步切换线程后读不到值导致丢失上下文。
所以本篇的设计原则只有一句话:引擎只负责推理,状态只挂在身份上,身份只在接入层生成一次。
后面第 04 章的架构、第 06 章的排查、第 07 章的优化,都是这句话的直接推论。
引擎越是干净
服务越是稳当
状态放在外面
谁也别怕并发
04、架构
[输入] HTTP REST / SSE 请求(Web Chat、企业 IM 回调)
|
v
[模块] Channel 实现:WebChatChannel / ImChannel
|
v
[网关] HarnessGateway:身份提取 + 会话解析 + 消息规范化
|
v
[状态] RuntimeContext:userId / sessionId / projectId
|
v
[处理] HarnessAgent 单例(无会话字段,可并发)
|
v
[输出] 同步 JSON 响应 / SSE 事件流
边界先划清楚:LiveKit 只负责 WebRTC 房间与实时音视频通道,AgentScope Java 负责推理、状态与后续的 Memory/Skill/Subagent 能力,Spring Boot 只是宿主与接入容器,Channel 是接入层抽象,HarnessGateway 是接入层与 harness 之间的翻译层。谁也别越界去干别人的活。
收益有三条。第一,引擎无状态化之后,单例注入就是安全的,不用为每次请求重建 Agent。第二,接入层可替换,Web Chat 与企业 IM 各自只做一个适配,业务逻辑只有一份。第三,身份与会话被显式建模成对象,串号问题从“偶发奇案”变成“类型问题”。
代价也有三条。多了一层抽象,新人需要先理解 Channel 与 Gateway 的分工;错误码与身份规范必须提前定,否则每个接入层各写一套;调试链路变长,从 HTTP 进来要跨过接入层、网关层才能到 Agent。
适用条件:接入层不止一个,或者预期会不止一个;服务需要多用户并发;会话状态需要跨请求连续。
不适用条件:只有单一接入层且短期不会变;纯 CLI 工具;单用户本地脚本。这些场景引入 Channel 抽象,属于自找复杂度。
本章涉及的 AgentScope 侧行为,判断依据应落到固定版本 v2.0.3 的源码与文档,而不是只读教程或社区总结。
接入只管接入
网关只管翻译
引擎只管推理
边界只管清楚
05、实战一次
以下代码是结构级参考实现,未在当前环境实测;Spring 侧代码可直接使用,AgentScope 侧(HarnessGateway 与 RuntimeContext)的具体方法名与构造方式,请以 v2.0.3 源码与官方文档 docs/v2/zh/docs/quickstart.md 核验。
环境与版本:Java 17+、Spring Boot 3.x、AgentScope Java v2.0.3。
依赖(Maven 片段):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
配置:
server:
port: 8080
agentscope:
harness:
model: ${LLM_MODEL}
system-prompt: "你是数字人助手,回答保持简短。"
第一步,身份对象。它只在接入层生成一次,之后全程只读传递:
public record ChatIdentity(String userId, String projectId, String sessionId) {
public ChatIdentity {
if (userId == null || userId.isBlank()) throw new IllegalArgumentException("userId");
if (projectId == null || projectId.isBlank()) throw new IllegalArgumentException("projectId");
if (sessionId == null || sessionId.isBlank()) throw new IllegalArgumentException("sessionId");
}
}
第二步,会话注册表。这是全系统唯一允许“有状态”的地方,状态按 key 隔离:
@Component
public class SessionRegistry {
private final Map<String, SessionSlot> slots = new ConcurrentHashMap<>();
public SessionSlot slotOf(ChatIdentity id) {
String key = id.userId() + ":" + id.projectId() + ":" + id.sessionId();
return slots.computeIfAbsent(key, k -> new SessionSlot(id));
}
public static final class SessionSlot {
private final ChatIdentity identity;
private final AtomicBoolean busy = new AtomicBoolean(false);
private SessionSlot(ChatIdentity identity) {
this.identity = identity;
}
public ChatIdentity identity() { return identity; }
public boolean tryAcquire() { return busy.compareAndSet(false, true); }
public void release() { busy.set(false); }
}
}
第三步,引擎端口。Spring 侧只依赖端口,不直接依赖框架类型:
public interface HarnessEngine {
String chat(ChatIdentity identity, String text);
void stream(ChatIdentity identity, String text, Consumer<String> onChunk);
}
第四步,适配器。把身份翻译成官方 RuntimeContext,再交给 HarnessGateway:
@Component
public class HarnessEngineAdapter implements HarnessEngine {
private final HarnessGateway gateway;
public HarnessEngineAdapter(HarnessGateway gateway) {
this.gateway = gateway;
}
@Override
public String chat(ChatIdentity identity, String text) {
return gateway.chat(toRuntimeContext(identity), text);
}
@Override
public void stream(ChatIdentity identity, String text, Consumer<String> onChunk) {
gateway.stream(toRuntimeContext(identity), text, onChunk::accept);
}
private RuntimeContext toRuntimeContext(ChatIdentity identity) {
return RuntimeContext.of(identity.userId(), identity.sessionId(), identity.projectId());
}
}
第五步,错误码。接入层与业务层共用一套码,不要各写各的:
public enum ErrorCode {
OK("0", "成功"),
UNAUTHORIZED("40100", "身份缺失或无效"),
PROJECT_NOT_FOUND("40401", "数字人项目不存在"),
SESSION_BUSY("40901", "该会话正在处理上一条消息"),
ENGINE_ERROR("50001", "推理引擎异常");
private final String code;
private final String message;
ErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
public String code() { return code; }
public String message() { return message; }
}
第六步,同步 REST 接口:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final SessionRegistry registry;
private final HarnessEngine engine;
public ChatController(SessionRegistry registry, HarnessEngine engine) {
this.registry = registry;
this.engine = engine;
}
@PostMapping
public ChatResponse chat(@RequestHeader("X-User-Id") String userId,
@RequestBody ChatRequest req) {
ChatIdentity id = new ChatIdentity(userId, req.projectId(), req.sessionId());
SessionRegistry.SessionSlot slot = registry.slotOf(id);
if (!slot.tryAcquire()) {
return ChatResponse.fail(ErrorCode.SESSION_BUSY, id.sessionId());
}
try {
String reply = engine.chat(id, req.text());
return ChatResponse.ok(id.sessionId(), reply);
} catch (RuntimeException ex) {
return ChatResponse.fail(ErrorCode.ENGINE_ERROR, id.sessionId());
} finally {
slot.release();
}
}
}
第七步,SSE 流式接口。注意 onChunk 是在别的线程上回调的,异常必须往回抛,不能吞:
@RestController
@RequestMapping("/api/chat")
public class ChatStreamController {
private final SessionRegistry registry;
private final HarnessEngine engine;
private final ExecutorService pool;
public ChatStreamController(SessionRegistry registry,
HarnessEngine engine,
ExecutorService chatPool) {
this.registry = registry;
this.engine = engine;
this.pool = chatPool;
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestHeader("X-User-Id") String userId,
@RequestBody ChatRequest req) {
ChatIdentity id = new ChatIdentity(userId, req.projectId(), req.sessionId());
SseEmitter emitter = new SseEmitter(120_000L);
SessionRegistry.SessionSlot slot = registry.slotOf(id);
if (!slot.tryAcquire()) {
emitter.completeWithError(new IllegalStateException(ErrorCode.SESSION_BUSY.code()));
return emitter;
}
pool.submit(() -> {
try {
engine.stream(id, req.text(), chunk -> {
try {
emitter.send(SseEmitter.event().name("delta").data(chunk));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (Exception ex) {
emitter.completeWithError(ex);
} finally {
slot.release();
}
});
return emitter;
}
}
线程池单独注册,避免和 Web 容器线程混用:
@Configuration
public class ChatExecutorConfig {
@Bean(destroyMethod = "shutdown")
public ExecutorService chatPool() {
return Executors.newFixedThreadPool(32);
}
}
启动与请求(未在当前环境实测,以下为预期结果):
curl -N -X POST http://localhost:8080/api/chat/stream \
-H "X-User-Id: u-1001" \
-H "Content-Type: application/json" \
-d '{"projectId":"p-01","sessionId":"s-a","text":"你好"}'
示例输出(预期形态,非实测结果):
event:delta
data:你
event:delta
data:好
event:done
data:[DONE]
验收方式:开两个终端,分别用 u-1001 和 u-1002 并发请求同一个 projectId,观察回复是否互相污染;再用同一个 u-1001 连续请求两次,观察是否延续上文。
一次 REST 同步
一次 SSE 流式
一次身份映射
一次最小验收
06、排查
证据链一:并发下回复串号。
现象:压测两个用户并发,偶尔出现 A 的回复里带着 B 的问题关键词。
怀疑:第一反应是框架线程安全问题,怀疑 HarnessAgent 内部状态没清干净。
检查:先不猜框架。把自己的装配代码翻出来,看 Agent 对象在哪儿被创建、被谁持有;再把请求入口的字段打印出来,确认 userId 和 sessionId 进来时是否已经正确。
证据:检查中发现 Agent 的构建类里存在类似“上一次会话标识”这类额外字段,被多个请求共享;同时会话历史放在了一个 static 容器里,key 只用了 projectId,没有带 userId。
根因:会话状态被挂在了共享对象上,且会话 key 的维度不够——只按项目隔离,没有按用户隔离。这不是框架问题,是状态归属问题。
修复:状态迁移到 SessionRegistry 的 slot 中,key 改为 userId:projectId:sessionId 三元组;Agent 侧不再保留任何会话字段。
证据链二:SSE 首字节迟迟不来。
现象:同步接口几十毫秒返回,流式接口十几秒才开始吐字,前端以为接口挂了。
怀疑:怀疑模型首 token 延迟,或者推理链路有问题。
检查:在 emitter.send 前后加时间戳,对比网关日志与服务端日志的时间差。
证据:服务端日志显示第一次 send 发生得很早,但客户端很晚才收到。差异集中在响应头与缓冲区行为上,说明问题出现在响应写出路径,而不是推理路径。
根因:SSE 响应缺少必要的即时写出配置,且上层代理对响应做了缓冲。
修复:显式声明 produces = TEXT_EVENT_STREAM_VALUE,确保 SseEmitter 不被包装;代理层对 /api/chat/stream 关闭响应缓冲。
错误尝试: 有同学提出用 ThreadLocal<RuntimeContext> 在 Controller 里存上下文,Service 里随取随用,认为这样“天然隔离”。
这个做法错在两处。第一,SSE 场景下请求线程与推理回调线程不是同一条,ThreadLocal 在回调线程里读不到值,表现为上下文凭空丢失。第二,线程池会复用线程,如果 finally 里没清理干净,下一位用户可能读到上一位用户的上下文,表现为偶发串号——而且这种 bug 在低并发下几乎复现不出来。
上游贡献判断:
本章在最小 Demo 中未复现出框架侧缺陷,未达到提 Issue 的证据门槛。如果你怀疑遇到了上游问题,按这个顺序核验:能否在最小 Demo 复现 → v2.0.3 是否存在 → 对比 main 分支是否仍存在 → 搜索官方 Issues/PR/Discussions 是否已有相同问题 → 已有则补充复现证据并参与讨论,没有则先提 Issue。当前结论:只能记录候选,不进入 Issue/PR。
证据不问猜测
断点不问日志
线程不背黑锅
状态才是元凶
07、优化
根因只有一条:状态放错了地方,错误也放错了地方。所以 V2 做两件事——状态外移、错误收口。
修改一,把所有会话级状态从 Agent 侧彻底移出,统一收敛到 SessionRegistry,并强制 key 使用三元组。理由:只要状态挂在共享对象上,无论怎么加锁都只是把并发问题变成排队问题,而排队会牺牲吞吐。新行为是引擎侧可无状态复用,同会话串行、跨会话并行。
修改二,把错误码从散落的 try/catch 提升为 ErrorCode 枚举,Controller 只负责映射,不负责编造文案。理由:接入层一旦不止一个,错误语义必须集中定义,否则 Web 与 IM 会给出不一致的提示。新行为是任何接入层看到同一套码与同一套语义。
修改三,为每个会话加一把“单会话串行锁”,而不是给整个服务加锁。SessionSlot.tryAcquire() 在会话忙碌时直接返回 SESSION_BUSY,而不是让请求排队。理由:同一个人同一秒发两条消息,业务上更应该提示“稍等”,而不是无限堆积。
修改四,身份来源标准化:Web 端从鉴权中间件写 X-User-Id,IM 端从平台回调里解析出平台用户 ID 再映射为内部 userId。理由:身份必须在接入层就归一化,越往后传越容易变形。
验证方法:并发两用户,观察是否出现跨用户串号;同用户连发两条,观察第二条是否返回 SESSION_BUSY 而不是互相踩踏;同用户串行两次,观察第二轮是否延续第一轮上下文。这三点通过,本篇验收即成立。
状态外移一步
并发轻松一层
错误收口一处
回滚少掉一半
08、演进
[同一输入] 一次带 userId / sessionId 的聊天请求
|
+--[V1] main() 单例 + 全局变量
| 代价:并发串号、重启失忆、接入层写死
|
+--[V2] Spring Boot 单例引擎 + Channel 网关 + RuntimeContext
| 代价:多一层抽象、需要错误码与身份规范
|
[Trade-off]
得到:可并发、可换接入层、状态可连续
失去:命令行式的简单直接、调试链路变长
逐项比较:
正确性上,V1 在单用户下完全正确,多用户下没有正确性可言;V2 把正确性建立在“状态按身份隔离”这一条可检查的规则上。
稳定性上,V1 的问题随并发升高而放大,且难以复现;V2 把不可复现的问题转化为可预测的 SESSION_BUSY。
复杂度上,V1 的类少、路径短,但复杂度藏在共享变量里;V2 多出端口、适配器、注册表三层,复杂度被显式化。显式复杂度比隐式复杂度便宜。
成本上,V2 需要额外维护线程池、错误码、身份映射规范,运维上还要考虑 SSE 长连接对连接数的影响。
适用范围上,V1 适合 CLI 工具与本地验证;V2 适合任何要多用户、多接入层的常驻服务。
遗留问题有两处。会话注册表目前是单机内存实现,多实例部署时会话不能跨节点共享,需要引入外部存储;SSE 长连接在网关层的超时与重连策略还未定稿。
旧方案够用一人
新方案撑住一群
得到的叫可接入
失去的叫简单
09、洞见
9.1 加锁解决的是“同时写”,无状态解决的是“本来就不该写”
反直觉判断:给 Agent 加一把大锁,看起来最安全,实际上是把并发问题换成了吞吐问题,并且掩盖了真正的设计缺陷。
如果状态本来就不该挂在共享对象上,加锁只是让串行变成隐式串行——请求排队、延迟上升,但错还是在。先问状态该放哪儿,再考虑要不要锁。本案例中真正需要锁的粒度是“单个会话”,而不是“整个引擎”。
9.2 Channel 的价值不是少写代码,而是让接入层可替换
反直觉判断:只有一个接入层时,引入 Channel 抽象确实是在增加代码量;但第二个接入层出现的概率,通常被严重低估。
抽象的成本是当下可见的,收益是未来才兑现的。判断要不要抽,不看现在有几个接入层,看“第二个接入层出现时,重写一遍业务逻辑的代价有多大”。如果业务逻辑已经在 Controller 里长到几百行,那就该抽了。
9.3 什么时候不应该用本章这套东西?
如果数字人只服务单一接入层、并发量在个位数、短期没有企业 IM 接入计划,那本章的端口—适配器—注册表三层结构就是过度设计。此时一个 Controller 加一个单例 Agent,配合每请求重建上下文,已经够用。
同理,如果业务上根本不需要跨请求连续会话,每次都是独立问答,那么会话注册表也可以直接砍掉。抽象是为变化准备的,没有变化就不要预支成本。
9.4 SSE 不是“更好的 REST”,是另一种契约
反直觉判断:流式接口的复杂度不在服务端推字,而在连接生命周期管理——断线重连、超时、取消、错误如何传递,这些在同步接口里根本不存在。
同步接口的错误用一个 HTTP 状态码加一个 JSON 就够了;流式接口一旦开始推字,错误只能作为事件在流里传,客户端必须能处理“半截回复”。所以选同步还是流式,应该由交互形态决定,而不是由“流式显得更先进”决定。
9.5 身份必须在接入层完成归一化,越往后越危险
Web 端的身份来自鉴权中间件,IM 端的身份来自平台回调,两者形态完全不同。如果在业务层才做映射,你会得到两套判断逻辑和两个 Bug 来源。把身份提取压到 Channel 边界,后面所有代码都只需要认识一个 userId。
单例未必危险
有态才最危险
组件越薄越稳
边界越清越久
10、系统落地
原来有什么:一个跑在命令行里的 Harness Agent,能推理、能调 Tool、有 Memory,但只服务一个人,接入层为零。
本篇新增什么:Spring Boot 宿主;单例无状态 HarnessAgent 注入;同步 REST 与 SSE 流式两个接口;SessionRegistry 会话隔离;ErrorCode 统一错误码;ChatIdentity 身份模型;以及以 Channel 与 HarnessGateway 为核心的接入层边界。分支为 chapter/26-spring-channel,提交为 feat(ch26): expose Spring Boot and channel gateway。
现在能做什么:Web 客户端可以多用户并发流式聊天,同一 user/session 状态连续,不同用户之间不串;接入层的第二个实现(企业 IM)只需要新增一个适配,不需要改动业务逻辑。
还缺什么:会话注册表还是单机内存;没有持久化;没有限流与鉴权细节;SSE 连接数在网关侧的上限未评估;多实例部署时会话不共享。
下一步如何演进:先把会话存储从内存迁到外部存储以支持多实例,再补鉴权与限流;接入层继续按 Channel 的抽象扩展,接入的渠道越多,越能验证这套边界是否真的够薄。
开源参与点:本章未发现明确上游问题。
服务起在端口
身份落在网关
状态留在外面
演进才有余地
11、小结
Q1 线程安全靠什么? → 靠“引擎无状态 + 状态外置”
Q2 会话存在哪儿? → RuntimeContext / 会话注册表,按 userId+projectId+sessionId 定位
Q3 接入层要不要抽象? → 要,用 Channel 收敛,Web 与 IM 只做适配
状态 → Web 多用户并发流式聊天可用,同会话连续、跨用户隔离
Q1 → 线程安全不靠给 Agent 加锁,而靠让引擎不持有会话状态;状态挂在身份上,挂在 SessionRegistry 里。
Q2 → 会话由 ChatIdentity 三元组定位,向下翻译成 RuntimeContext;同会话串行、跨会话并行,忙碌时以 SESSION_BUSY 显式反馈。
Q3 → 接入层用 Channel 抽象收敛,Web Chat 与企业 IM 各自只做适配,业务逻辑只有一份。
状态 → 本章可交付能力:一个可在 Spring Boot 中多用户并发服务、接入层可替换、会话可连续的数字人后端。
问的是并发
答的是无状态
收的是边界
留的是演进
12、作业
12.1 理解题:为什么把 HarnessAgent 注册成 Spring 单例 Bean,不等同于“线程不安全”?
参考答案:线程安全取决于是否持有可变共享状态,而不是取决于是否单例。请打开 v2.0.3 的 Channel.java 与 HarnessGateway.java,确认接入层与网关层的职责边界,再回看 Agent 侧是否持有会话字段。如果 Agent 只负责推理、会话信息通过 RuntimeContext 参数传入,那么单例复用是安全且更省成本的;反之,如果 Agent 内部存了“上一次会话”,那和它是不是单例无关,它本来就不安全。
12.2 实战题:在本章分支上实现 SSE 流式接口,并验证同会话连续、跨用户隔离。
参考答案:按第 05 章的结构实现 SseEmitter + SessionRegistry,注意三点:onChunk 在推理线程回调,异常必须抛回外层;slot.release() 必须放在 finally;忙碌时返回 SESSION_BUSY 而不是排队。验证方式:两个 X-User-Id 并发请求,观察是否互相污染;同一 X-User-Id 连续两次请求,观察第二轮是否延续上下文。
12.3 排障题:两个用户并发聊天时偶尔看到对方回复,你的定位顺序是什么?
参考答案:先怀疑自己的状态归属,再怀疑框架。顺序是:确认会话 key 的维度是否包含 userId;确认会话状态是否挂在共享对象上;确认是否用了 ThreadLocal 且未在 finally 清理;最后才考虑框架侧。第 06 章的证据链一给出了完整路径:现象 → 怀疑 → 检查 → 证据 → 根因 → 修复。
12.4 架构判断题:如果公司只有 Web 一个接入层,且一年内不会增加,是否值得引入 Channel 抽象?
参考答案:值得与否取决于“变化成本”而不是“当前数量”。如果聊天业务逻辑还很薄,可以先用一个 Controller 加一个端口,把接入相关代码集中在一个包里,暂时不实现完整的 Channel 抽象;但边界要先在心里划清,等第二个接入层出现时再抽,代价最小。反过来,如果业务逻辑已经很厚,早点抽更划算。
会跑不改算会跑
会读源码才叫懂
会改上游不容易
会留证据才作数
13、思考
回到本篇的核心冲突:Agent 是有状态的对象,HTTP 是无状态的协议,这两者天生互相伤害。
我们的答案不是让它们互相妥协,而是把状态从引擎里赶出去,挂到身份上,再用接入层抽象把“谁在说话”这件事定义清楚。线程安全不是加锁加出来的,是边界划出来的。
真正的工程判断只有一句:当并发和接入层变化同时到来时,先问状态归谁,再问代码放哪,最后才问要不要锁。顺序反了,怎么写都会绕回来。
会用框架,是知道怎么把它跑起来;能参与框架演进,是知道它在什么条件下会失效,并且能拿出最小复现。
一念辨边界
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号