知与行开发记录

神秘人 Agent 部署在 CloudBase 云托管(容器模式),同一容器进程处理多用户并发请求。Agent 通过全局变量 `CURRENT_USER_ID`(`tools.py`)和 `USER_ID`(`agent/runtime.py`)在工具执行时获取当前用户身份。

by 于尘 ·
知与行 CloudBase Agent

记录日期:2026-04-11

1. 问题背景

神秘人 Agent 部署在 CloudBase 云托管(容器模式),同一容器进程处理多用户并发请求。Agent 通过全局变量 CURRENT_USER_IDtools.py)和 USER_IDagent/runtime.py)在工具执行时获取当前用户身份。

当两个用户请求同时到达同一进程时:

请求A: set_user("user_a") → 写 CURRENT_USER_ID = "user_a"
      请求B: set_user("user_b") → 写 CURRENT_USER_ID = "user_b"
      请求A: tools.create_plan() → 读 CURRENT_USER_ID = "user_b" ← 错误!
      

CURRENT_USER_ID 是模块级全局变量,不具备任何并发隔离能力。用户 A 的请求可能执行了用户 B 的操作,导致数据跨用户污染


2. 问题列表

2.1 多用户并发下 CURRENT_USER_ID 竞态

根因

  • why1: 为什么工具执行时拿到错误的用户 ID?因为 CURRENT_USER_ID 全局变量在请求 A 写入后、工具执行前,被请求 B 的写入覆盖了
  • why2: 为什么会被请求 B 覆盖?因为 CURRENT_USER_ID 是进程级全局变量,没有任何隔离机制
  • why3: 为什么使用全局变量传递用户 ID?因为早期设计时未考虑并发场景,直接复用了模块级变量作为"上下文"

方案

  • 短期方案:引入 threading.Lock,在 set_user_id()get_user_id() 调用处加锁保护

    • 优点:改动最小,改动点集中
    • 缺点:所有请求串行化执行,并发能力退化;Lock 无法防护 asyncio 协程切换
  • 长远方案:使用 contextvars.ContextVar 实现协程级别的上下文本地变量

    • 优点:asyncio 任务之间完全隔离,不影响并发性能;Python 标准库,无需引入新依赖
    • 缺点:需要改造所有读取/写入 user_id 的代码路径

采纳方案: 长远方案 —— contextvars.ContextVar

补充用例

场景1(修复后):请求A设置 ctx_uid="user_a",请求B设置 ctx_uid="user_b",两者互不影响
      场景2(修复后):asyncio.create_task() 产生的子协程继承父协程的 ctx_uid,行为正确
      场景3(修复前):请求A的工具在执行中途,请求B修改了 CURRENT_USER_ID,导致脏读
      

3. 小结

通过 ContextVar 替代全局变量,实现每个 asyncio 协程拥有独立的用户 ID 上下文。请求入口(app.py_confirm_request_preprocessor)调用 set_current_user(user_id) 设置上下文,工具执行时通过 get_user_id() 读取,始终与请求绑定,不再有任何跨请求污染风险。

涉及改动文件:

  • agent/runtime.py — 核心:引入 ContextVar
  • tools.py — 移除全局变量,改用 ContextVar
  • agent/__init__.py — 导出更新
  • agent/nodes/execute_tools.py — 读取方式更新
  • agent/nodes/persistence.py — 读取方式更新
← 所有文章

Agent-create_plan参数提取与confirm流程修复

# Agent create_plan 参数提取与 confirm 流程(当前实现)

by 于尘 ·
知与行 CloudBase

Agent create_plan 参数提取与 confirm 流程(当前实现)

1.问题背景

用户说「给我创建一个计划,标题是跑步」时,风险点有两类:

  1. plan_title 被 LLM 填成整句(含“给我创建一个计划”这类元指令)。
  2. 确认流程字段传错导致工具未执行或执行错参数。

当前系统已切到 interrupt/resume 两轮确认,不再使用旧的 persistence_node / confirm_recovery_node 口径。

2.问题列表

2.1 问题一:plan_title 抽取质量不稳定(模型行为问题)

根因(链式证据级定位,最多3轮):

  • why1: 为什么标题偶发带元指令?
    • 因为参数提取由 LLM 完成,本质是生成行为,不是规则解析。
  • why2: 为什么会影响确认与落库?
    • 因为后端当前不做 plan_title 归一化,工具按收到参数直接执行。
  • why3: 为什么不在后端强行正则清洗?
    • 因为清洗规则容易误伤真实标题,且会引入隐式魔法行为。

方案(每个问题不超过两个方案):

  • 短期方案: 在工具说明里强化参数语义约束(当前已做)。
    • 优点: 改动小、与 LLM 机制一致。
    • 缺点: 不是 100% 确定性。
  • 长远方案: 前端确认弹窗默认支持 edit 决策,用户可改标题后再执行。
    • 优点: 用户可控、结果确定。
    • 缺点: 交互更复杂。

采纳方案:

  • 当前采纳“短期方案 + 长远兜底”:
    • 工具 docstring 强约束 plan_title 只填核心标题。
    • 保留 edit 决策链路用于纠偏(后端已支持)。

补充用例:

  • tests/e2e/test_012_edit_decision.py:验证确认轮 edit 后参数生效。

2.2 问题二:confirm 恢复请求字段错传导致执行失败

根因(链式证据级定位,最多3轮):

  • why1: 为什么用户点确认后偶发不执行?
    • 因为前端若未按协议传 forwardedProps.command.resume,后端不会进入恢复执行。
  • why2: 为什么这在旧文档里容易被误配?
    • 因为历史文档混用了 state.confirm、role=tool 等旧口径。
  • why3: 为什么必须统一?
    • 因为当前工作流只认 Command(resume=...)

方案(每个问题不超过两个方案):

  • 短期方案: 文档统一到 resume.decisions + pending_calls
    • 优点: 立刻减少错传。
    • 缺点: 依赖前端严格执行。
  • 长远方案: 前后端加协议契约测试(真实 SSE 回放 + confirm 二轮)。
    • 优点: 回归可自动拦截。
    • 缺点: 测试建设成本更高。

采纳方案:

  • 采纳长远方案方向,现阶段已完成文档口径统一,并保留 e2e confirm 用例覆盖。

补充用例:

  • Round1 必须拿到 pending_calls
  • Round2 必须传 resume.decisionsresume.pending_calls
  • 结果必须出现 TOOL_CALL_START/ENDcreate_plan 工具结果。

2.3 问题三:前端确认后误回放上一轮 assistant 文本(显示层问题)

根因(链式证据级定位,最多3轮):

  • why1: 为什么确认后会多出一条旧回复?
    • 因为确认回合里 result.content 有时是快照回放文本。
  • why2: 为什么会被直接展示?
    • 因为前端曾优先使用 llmText 作为确认后的展示内容。
  • why3: 为什么影响用户感知?
    • 因为用户会看到“重复回复”,误以为执行了两次。

方案(每个问题不超过两个方案):

  • 短期方案: 前端优先结构化工具结果,并过滤“与上一条 assistant 完全相同”的回放文本。
    • 优点: 立刻止血。
    • 缺点: 是展示层防线。
  • 长远方案: 协议层明确区分确认回合的“新生成文本”与“快照回放文本”。
    • 优点: 从协议源头解决。
    • 缺点: 需前后端联动改造。

采纳方案:

  • 当前采纳短期方案,已在前端确认回合生效。

补充用例:

  • confirm 后不应重复追加上一轮 assistant 文本。
  • 无结构化结果时应回退到工具摘要文案。

3.小结

当前 create_plan 链路是:

  1. 参数提取由 LLM 主导,靠工具描述做语义约束。
  2. 写工具执行走 interrupt/resume 两轮确认。
  3. 前端确认展示层已增加“重复回放过滤”。

下一步最值得做的是:把 create_plan 的“标题提取准确率”做成可量化回归样本(真实语料回放 + 判定),而不是继续堆正则。

← 所有文章