恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
【共创稿事节】HarmonyOS 7 Agent 开发实战 11:并发多会话隔离
首页
资讯中心
/
【共创稿事节】HarmonyOS 7 Agent 开发实战 11:并发多会话隔离
【共创稿事节】HarmonyOS 7 Agent 开发实战 11:并发多会话隔离
发布时间:2026/10/18 5:46:28
02 篇的ConversationStore解决的是「一场对话怎么存」。但真实 App 从来不止一场对话用户可能在 A 会话问报销、在 B 会话查订单后台可能同时跑着一个定时摘要任务和一个前台问答。如果所有会话共用一个ConversationStoreA 问的「密码」会被 B 的上下文读到B 的订单号会污染 A 的记忆——这就是上下文串台。串台最麻烦的地方在于它不报错模型照样返回一段像模像样的话只是答案用错了上下文。等到用户发现「我明明没提过这个订单号」排查成本已经很高了。11 这一篇给出根治方案每个会话一套独立的ConversationStoreAgent由SessionManager统一登记、隔离、淘汰。三种隔离粒度该隔离到哪一层隔离不是越彻底越好要在「正确性」与「开销」之间取平衡隔离粒度做法能防住什么代价只隔离消息每会话一个ConversationStore共用一个Agent历史消息不串台防不住——Agent内的工具循环、流式累积状态仍会跨会话污染隔离 store Agent本文每会话一套ConversationStoreAgentLlmClient消息与编排状态都不串台会话多时对象数量上升需要容量淘汰隔离到线程每会话一个 Worker连并发调度都物理隔离线程开销大适合少量长任务而非普通对话结论很直接绝大多数 App 做到第二档就够了。第一档是常见的「看起来做了其实没做」——消息是不串了但 03 的工具循环、05 的流式累积状态还挂在共用的Agent上照样会串。第三档留给后台长任务普通聊天会话用不上。隔离的最小单元SessionEntry隔离的本质是「每个会话持有自己的状态」而不是「每次调用传一个 sessionId 进去筛选」。前者是状态隔离后者是查询过滤——后者只要有一处忘记传 id串台就发生了。// 单条会话的隔离单元每条会话持有自己独立的 ConversationStore 与 Agent// 消息在内存中互不串台。lastAccess 用单调递增计数器排序保证 LRU 与时钟精度无关。exportclassSessionEntry{readonlysessionId:string;readonlystore:ConversationStore;// 该会话私有的对话记忆readonlyagent:Agent;// 该会话私有的编排实例lastAccess:number;// 越大表示越近被访问constructor(sessionId:string,client:LlmClient,systemPrompt:string,model:string){this.sessionIdsessionId;this.storenewConversationStore(systemPrompt);this.agentnewAgent(client,systemPrompt,model);this.lastAccess0;}// 该会话已落库不含系统消息的对话条数getmessageCount():number{returnthis.store.size();}}三个字段各自独立缺一不可store私有消息落进自己的数组getConversation()拿到的永远是本会话的。agent私有Agent内部持有LlmClient与编排状态如 03 的工具循环、05 的流式累积这些也必须按会话隔离否则 A 会话的工具调用会写进 B 的循环里。clientFactory逐会话生成不同会话可能需要不同的模型或密钥比如付费用户走大模型、免费用户走小模型所以客户端由工厂按sessionId现造而不是全局共用一个。SessionManager登记、取用、淘汰管理器用Mapstring, SessionEntry做索引对外只有三个动作取没有就建、发消息、查数量。// 多会话管理器用 Map 隔离各会话上下文并基于访问顺序做 LRU 淘汰。// 纯逻辑、可测真正的并行执行交给 TaskPool/Worker见文章说明。exportclassSessionManager{privatereadonlysessions:Mapstring,SessionEntrynewMapstring,SessionEntry();privateclock:number0;// 单调递增访问计数器privateevictedTotal:number0;privatenow():number{this.clock;returnthis.clock;}// 取已有会话并刷新访问时间不存在则新建必要时先淘汰最久未访问者getOrCreate(sessionId:string):SessionEntry{constexisting:SessionEntry|undefinedthis.sessions.get(sessionId);if(existing!undefined){existing.lastAccessthis.now();returnexisting;}// 容量边界即将超过上限时先淘汰 LRU再放进新会话if(this.sessions.sizethis.maxSessions){this.evictLeastRecentlyUsed();}constclient:LlmClientthis.clientFactory(sessionId);constentry:SessionEntrynewSessionEntry(sessionId,client,this.systemPrompt,this.model);entry.lastAccessthis.now();this.sessions.set(sessionId,entry);returnentry;}// 委派给该会话自己的 Agent仅影响本会话的消息计数asyncsend(sessionId:string,userText:string):Promisestring{constentry:SessionEntrythis.getOrCreate(sessionId);entry.lastAccessthis.now();returnawaitentry.agent.send(userText);}}为什么 LRU 用单调计数器而不是 Date.now()这是本文最容易被忽略、但上线后一定会踩的坑。如果用Date.now()记录lastAccess同一毫秒内访问两个会话时两者的时间戳完全相同LRU 选出谁取决于Map的遍历顺序结果不可预测系统时钟可能被调整NTP 校时、用户改时间时间戳甚至可能回退导致「刚访问过的会话反而被判为最旧」。改成单调递增计数器就彻底规避了这两点谁先被访问谁的数字小同毫秒访问也有严格先后且与真实时钟无关。代价只是这个数字不能跨进程持久化——但 LRU 本来就是内存态策略重启动后重建即可。淘汰发生在「插入前」而不是「插入后」注意getOrCreate里的顺序先判断size maxSessions淘汰一个再放进新的。这样size()在任何时刻都不会超过maxSessions。如果写反先插后淘汰容量就会短暂超限一瞬而这一瞬恰好是内存峰值最危险的时候。privateevictLeastRecentlyUsed():void{letoldestId:string|undefinedundefined;letoldestTime:numberNumber.MAX_SAFE_INTEGER;for(const[id,entry]ofthis.sessions){if(entry.lastAccessoldestTime){oldestTimeentry.lastAccess;oldestIdid;}}if(oldestId!undefined){this.sessions.delete(oldestId);this.evictedTotal;}}真机上的「并发」到底在哪一层需要说清楚一个边界本文的SessionManager解决的是状态隔离它本身是纯逻辑、单线程安全的。真正的并行比如同时向模型发三个请求、互不阻塞要靠 HarmonyOS 的并发设施TaskPool适合「一次性、无状态、可并发」的任务。把每个会话的一次 LLM 调用包装成Concurrent函数丢进任务池系统按负载调度。任务函数不能捕获外部变量入参出参需可序列化——这恰好和「会话状态由SessionEntry持有、任务只带 id 和文本」的设计契合。Worker适合「长时间、有状态」的后台线程比如一个持续运行的定时摘要会话。两者都遵循同一条纪律共享的可变状态不要跨线程直接读写。所以SessionEntry里的store只由持有它的那个线程/任务更新管理器本身只在主线程做登记与淘汰。这样隔离逻辑既能单测迁移到真并发时也不用推倒重来。落到代码上TaskPool的任务函数只带可序列化的入参不捕获外部状态真正的落库仍回到持有方// 并发任务只带可序列化的入参不捕获外部状态ConcurrentfunctioncallLlmOnce(sessionId:string,userText:string):string{// 注意这里拿不到主线程的 SessionEntry只做一次无状态调用returnsessionId:userText;}// 主线程任务只负责跑状态更新回到持有方consttask:taskpool.Tasknewtaskpool.Task(callLlmOnce,A,查一下订单);constraw:stringawaittaskpool.execute(task)asstring;constreply:stringawaitmanager.send(A,查一下订单);// 落库仍在持有方完成这里的分工值得强调TaskPool负责「把一次耗时的网络调用丢出去并发跑」SessionManager负责「这次调用的结果该记到谁的账上」。把并发与归属拆开两者才能各自独立演进——换并发框架不影响隔离结构改隔离策略也不影响并发代码。演示页两个会话互不相干entry/src/main/ets/pages/Index.ets用MockLlmClient离线演示两个输入框分别绑定 sessionA和 sessionB各自发送后页面实时显示各自的消息条数。往 A 发 3 条、往 B 发 1 条A 显示 63 问 3 答、B 显示 2——互不干扰。「创建超出容量的会话」按钮会连续创建超过maxSessions的会话页面显示evictedTotalCount()增长证明最久未访问者被淘汰。测试隔离要能被断言隔离这种事肉眼在界面上很难证明必须落到断言隔离性往 A 发消息后messageCount(A)增加而messageCount(B)不变且A.store.getConversation()里不含 B 的文本。getOrCreate幂等同一 id 连续取两次返回同一个SessionEntry可用引用相等或消息计数断言。LRU 顺序maxSessions 2时依次访问 A、B再访问 A然后创建 C —— 被淘汰的应是 B 而不是 A。容量边界创建恰好maxSessions个不触发淘汰再创建第maxSessions 1个时evictedTotal变成 1。verify-node.cjs用纯 JS 镜像了同样的逻辑命令行 20 条断言全绿entry/src/ohosTest/ets/test/LlmClient.test.ets另有 Hypium 断言含 01/02 底座的复用部分不装 DevEco 也能先验纯逻辑。避坑六个常见错误只隔离消息不隔离 Agent。共用一个Agent实例时03 的工具循环、05 的流式累积状态会跨会话污染。对策连Agent乃至LlmClient一起按会话私有化。用Date.now()做 LRU 时间戳。同毫秒并列 时钟回退会让淘汰结果不可预测。对策改用单调递增计数器。先插入后淘汰。容量会短暂超限。对策插入前先判边界并淘汰。把sessionId当查询条件而不是隔离键。只要有一处漏传 id 就会串台。对策状态按会话持有别用「全局表 过滤」的写法。跨线程共享可变状态。TaskPool任务里直接改主线程的ConversationStore结果取决于调度时序。对策任务只带 id 与文本状态更新回到持有方。淘汰时忘了释放资源。会话里若持有流式连接、定时器淘汰前要显式关闭否则会泄漏。对策给SessionEntry加dispose()淘汰时调用。参考与出处HarmonyOS ArkTS 语言TaskPool 并发任务概述Worker 多线程概述Hypium 自动化测试框架使用指南最后一句隔离不是把 id 传下去而是让每个会话从一开始就拥有自己的一份状态——串台这种 bug靠约定防不住只能靠结构。