
02 篇的 ConversationStore 解决的是「一场对话怎么存」。但真实 App 从来不止一场对话:用户可能在 A 会话问报销、在 B 会话查订单;后台可能同时跑着一个定时摘要任务和一个前台问答。如果所有会话共用一个 ConversationStore,A 问的「密码」会被 B 的上下文读到,B 的订单号会污染 A 的记忆------这就是上下文串台。
串台最麻烦的地方在于它不报错:模型照样返回一段像模像样的话,只是答案用错了上下文。等到用户发现「我明明没提过这个订单号」,排查成本已经很高了。11 这一篇给出根治方案:每个会话一套独立的 ConversationStore + Agent,由 SessionManager 统一登记、隔离、淘汰。

三种隔离粒度:该隔离到哪一层
隔离不是越彻底越好,要在「正确性」与「开销」之间取平衡:
| 隔离粒度 | 做法 | 能防住什么 | 代价 |
|---|---|---|---|
| 只隔离消息 | 每会话一个 ConversationStore,共用一个 Agent |
历史消息不串台 | 防不住------Agent 内的工具循环、流式累积状态仍会跨会话污染 |
| 隔离 store + Agent(本文) | 每会话一套 ConversationStore + Agent + LlmClient |
消息与编排状态都不串台 | 会话多时对象数量上升,需要容量淘汰 |
| 隔离到线程 | 每会话一个 Worker | 连并发调度都物理隔离 | 线程开销大,适合少量长任务而非普通对话 |
结论很直接:绝大多数 App 做到第二档就够了 。第一档是常见的「看起来做了其实没做」------消息是不串了,但 03 的工具循环、05 的流式累积状态还挂在共用的 Agent 上,照样会串。第三档留给后台长任务,普通聊天会话用不上。
隔离的最小单元:SessionEntry
隔离的本质是「每个会话持有自己的状态」,而不是「每次调用传一个 sessionId 进去筛选」。前者是状态隔离,后者是查询过滤------后者只要有一处忘记传 id,串台就发生了。
typescript
// 单条会话的隔离单元:每条会话持有自己独立的 ConversationStore 与 Agent,
// 消息在内存中互不串台。lastAccess 用单调递增计数器排序,保证 LRU 与时钟精度无关。
export class SessionEntry {
readonly sessionId: string;
readonly store: ConversationStore; // 该会话私有的对话记忆
readonly agent: Agent; // 该会话私有的编排实例
lastAccess: number; // 越大表示越近被访问
constructor(sessionId: string, client: LlmClient, systemPrompt: string, model: string) {
this.sessionId = sessionId;
this.store = new ConversationStore(systemPrompt);
this.agent = new Agent(client, systemPrompt, model);
this.lastAccess = 0;
}
// 该会话已落库(不含系统消息)的对话条数
get messageCount(): number {
return this.store.size();
}
}
三个字段各自独立,缺一不可:
store私有 :消息落进自己的数组,getConversation()拿到的永远是本会话的。agent私有 :Agent内部持有LlmClient与编排状态(如 03 的工具循环、05 的流式累积),这些也必须按会话隔离,否则 A 会话的工具调用会写进 B 的循环里。clientFactory逐会话生成 :不同会话可能需要不同的模型或密钥(比如付费用户走大模型、免费用户走小模型),所以客户端由工厂按sessionId现造,而不是全局共用一个。
SessionManager:登记、取用、淘汰
管理器用 Map<string, SessionEntry> 做索引,对外只有三个动作:取(没有就建)、发消息、查数量。
typescript
// 多会话管理器:用 Map 隔离各会话上下文,并基于访问顺序做 LRU 淘汰。
// 纯逻辑、可测;真正的并行执行交给 TaskPool/Worker(见文章说明)。
export class SessionManager {
private readonly sessions: Map<string, SessionEntry> = new Map<string, SessionEntry>();
private clock: number = 0; // 单调递增访问计数器
private evictedTotal: number = 0;
private now(): number {
this.clock++;
return this.clock;
}
// 取已有会话(并刷新访问时间);不存在则新建,必要时先淘汰最久未访问者
getOrCreate(sessionId: string): SessionEntry {
const existing: SessionEntry | undefined = this.sessions.get(sessionId);
if (existing !== undefined) {
existing.lastAccess = this.now();
return existing;
}
// 容量边界:即将超过上限时,先淘汰 LRU,再放进新会话
if (this.sessions.size >= this.maxSessions) {
this.evictLeastRecentlyUsed();
}
const client: LlmClient = this.clientFactory(sessionId);
const entry: SessionEntry = new SessionEntry(sessionId, client, this.systemPrompt, this.model);
entry.lastAccess = this.now();
this.sessions.set(sessionId, entry);
return entry;
}
// 委派给该会话自己的 Agent,仅影响本会话的消息计数
async send(sessionId: string, userText: string): Promise<string> {
const entry: SessionEntry = this.getOrCreate(sessionId);
entry.lastAccess = this.now();
return await entry.agent.send(userText);
}
}
为什么 LRU 用单调计数器而不是 Date.now()
这是本文最容易被忽略、但上线后一定会踩的坑。如果用 Date.now() 记录 lastAccess:
- 同一毫秒内访问两个会话时,两者的时间戳完全相同 ,LRU 选出谁取决于
Map的遍历顺序,结果不可预测; - 系统时钟可能被调整(NTP 校时、用户改时间),时间戳甚至可能回退,导致「刚访问过的会话反而被判为最旧」。
改成单调递增计数器就彻底规避了这两点:谁先被访问谁的数字小,同毫秒访问也有严格先后,且与真实时钟无关。代价只是这个数字不能跨进程持久化------但 LRU 本来就是内存态策略,重启动后重建即可。
淘汰发生在「插入前」而不是「插入后」
注意 getOrCreate 里的顺序:先判断 size >= maxSessions,淘汰一个,再 放进新的。这样 size() 在任何时刻都不会超过 maxSessions。如果写反(先插后淘汰),容量就会短暂超限一瞬,而这一瞬恰好是内存峰值最危险的时候。
typescript
private evictLeastRecentlyUsed(): void {
let oldestId: string | undefined = undefined;
let oldestTime: number = Number.MAX_SAFE_INTEGER;
for (const [id, entry] of this.sessions) {
if (entry.lastAccess < oldestTime) {
oldestTime = entry.lastAccess;
oldestId = id;
}
}
if (oldestId !== undefined) {
this.sessions.delete(oldestId);
this.evictedTotal++;
}
}
真机上的「并发」到底在哪一层
需要说清楚一个边界:本文的 SessionManager 解决的是状态隔离,它本身是纯逻辑、单线程安全的。真正的并行(比如同时向模型发三个请求、互不阻塞)要靠 HarmonyOS 的并发设施:
TaskPool:适合「一次性、无状态、可并发」的任务。把每个会话的一次 LLM 调用包装成@Concurrent函数丢进任务池,系统按负载调度。任务函数不能捕获外部变量,入参出参需可序列化------这恰好和「会话状态由SessionEntry持有、任务只带 id 和文本」的设计契合。Worker:适合「长时间、有状态」的后台线程,比如一个持续运行的定时摘要会话。
两者都遵循同一条纪律:共享的可变状态不要跨线程直接读写 。所以 SessionEntry 里的 store 只由持有它的那个线程/任务更新,管理器本身只在主线程做登记与淘汰。这样隔离逻辑既能单测,迁移到真并发时也不用推倒重来。
落到代码上,TaskPool 的任务函数只带可序列化的入参,不捕获外部状态;真正的落库仍回到持有方:
typescript
// 并发任务:只带可序列化的入参,不捕获外部状态
@Concurrent
function callLlmOnce(sessionId: string, userText: string): string {
// 注意:这里拿不到主线程的 SessionEntry,只做"一次无状态调用"
return sessionId + ':' + userText;
}
// 主线程:任务只负责"跑",状态更新回到持有方
const task: taskpool.Task = new taskpool.Task(callLlmOnce, 'A', '查一下订单');
const raw: string = await taskpool.execute(task) as string;
const reply: string = await manager.send('A', '查一下订单'); // 落库仍在持有方完成
这里的分工值得强调:TaskPool 负责「把一次耗时的网络调用丢出去并发跑」,SessionManager 负责「这次调用的结果该记到谁的账上」。把并发与归属拆开,两者才能各自独立演进------换并发框架不影响隔离结构,改隔离策略也不影响并发代码。
演示页:两个会话互不相干
entry/src/main/ets/pages/Index.ets 用 MockLlmClient 离线演示:
- 两个输入框分别绑定 session
A和 sessionB,各自发送后页面实时显示各自的消息条数。往 A 发 3 条、往 B 发 1 条,A 显示 6(3 问 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(),淘汰时调用。
参考与出处
最后一句:隔离不是把 id 传下去,而是让每个会话从一开始就拥有自己的一份状态------串台这种 bug,靠约定防不住,只能靠结构。