【共创稿事节】HarmonyOS 7 Agent 开发实战 11:并发多会话隔离

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 和 session B,各自发送后页面实时显示各自的消息条数。往 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 也能先验纯逻辑。

避坑:六个常见错误

  1. 只隔离消息不隔离 Agent 。共用一个 Agent 实例时,03 的工具循环、05 的流式累积状态会跨会话污染。对策:连 Agent(乃至 LlmClient)一起按会话私有化。
  2. 用 Date.now() 做 LRU 时间戳。同毫秒并列 + 时钟回退会让淘汰结果不可预测。对策:改用单调递增计数器。
  3. 先插入后淘汰。容量会短暂超限。对策:插入前先判边界并淘汰。
  4. 把 sessionId 当查询条件而不是隔离键。只要有一处漏传 id 就会串台。对策:状态按会话持有,别用「全局表 + 过滤」的写法。
  5. 跨线程共享可变状态 。TaskPool 任务里直接改主线程的 ConversationStore,结果取决于调度时序。对策:任务只带 id 与文本,状态更新回到持有方。
  6. 淘汰时忘了释放资源 。会话里若持有流式连接、定时器,淘汰前要显式关闭,否则会泄漏。对策:给 SessionEntry 加 dispose(),淘汰时调用。

参考与出处


最后一句:隔离不是把 id 传下去,而是让每个会话从一开始就拥有自己的一份状态------串台这种 bug,靠约定防不住,只能靠结构。

相关推荐
行者-全栈开发5 小时前
【共创稿事节】HarmonyOS 7 空间音频实战:用 AudioSpatializationManager 构建“听得见方位“的声场
harmonyos·arkts·鸿蒙·空间音频·api 26·audio kit
马剑威(威哥爱编程)5 小时前
【共创稿事节】HarmonyOS 7 Agent 开发实战 03:Tool Calling 让 AI 查实时订单与库存
华为·harmonyos
倔强的石头1067 小时前
把 Typst 搬上开源鸿蒙 PC:一次“干净”的移植记录
华为·开源·harmonyos
梦想不只是梦与想10 小时前
鸿蒙 预览数据模拟:Hamock
harmonyos·模拟数据·hamock
贾伟康12 小时前
【化学实验室|04】HarmonyOS ArkTS 场景选择器实战:让 13 个离线实验的入口、筛选和返回路径保持一致
harmonyos·arkts·canvas·arkui·化学实验
特立独行的猫a13 小时前
仓颉语言GUI开发实战:用仓颉写串口/网络二合一调试助手
网络·ui·gui·harmonyos·tauri·仓颉
hqzing1 天前
把 glibc 移植到鸿蒙 PC 上:它能用来做什么
华为·harmonyos
OH_TPC1 天前
HarmonyOS APP开发---“习惯猫“习惯打卡App,需要用到这个库
华为·harmonyos·鸿蒙
熊猫钓鱼>_>1 天前
开源鸿蒙平台 KMP 三方库 kotlin-csv 适配全流程:从编码假设到沙箱真机互通验证
开发语言·人工智能·华为·kotlin·harmonyos·鸿蒙