Agent 状态持久化:中断需用户确认场景
记录日期:2026-04-02
1. 问题背景
神秘人 Agent(shenmiren-agent)在「先提案、用户确认、再执行工具」路径下,用 文档库 存会话与待确认工具调用,用 RDB REST 执行 create_plan 等。该链路里曾先后出现:网关 401、HTTP 失败无结构化体、确认二次请求 读不回会话、user_id 落 default、[工具成功] 与 success: false 矛盾、SSE TOOL_CALL_RESULT 缺 payload 致前端严格校验失败(2.7)、集成测/真机遇到的 上游 LLM 限流(如 2064,与 NoSQL 无关,2.8)、可绑定客户端工具清空后 仍只依赖 服务端 pending + App 确认(2.9)、persistence 图拓扑导致同轮偷跑写库 + 快照无 needs_confirmation 致 App 不弹确认(2.10),以及执行侧 整句当 plan_title 入 RDB(2.11,关联)。下文按 docs/开发记录博客/agents.md 博客格式(根因链 → 短期/长远方案 → 采纳 → 用例)记录,并与仓库 错误不可静默、多端一致要求对齐。
2. 问题列表
2.1 HTTP 失败无响应体与错误分层不清
根因
- why1:
CloudBaseClient曾在 HTTP 失败时返回None,调用方拿不到 状态码与 body。 - why2:传输失败与业务失败混在笼统
message里,日志与单测难断言。 - why3:若 L3 展示不按工具返回的
success,会出现 成功前缀 + 失败 JSON(与 2.5 同源)。
方案(每个问题不超过两个)
- 短期方案:L0 返回
_http_error字典(含status_code、body/body_json、code如GATEWAY_UNAUTHORIZED);L2tool_failure_from_gateway带error块;L3execute_tools以返回 dict 的success写记录并生成说明文案。
优点:可测、可对照官方 HTTP API。缺点:需梳理 RDB 调用分支。
- 长远方案:全链路统一传输/业务错误码 + SSE 可机读
error事件。
优点:多端一致。缺点:工作量大,需分阶段。
采纳方案
- 已实现:L0
cloudbase_client;L2tools.tool_failure_from_gateway;L3execute_tools_node+_format_tool_execution_message(失败支路含error_detail)。 - 速查:L0 传输 → L1 网关
code→ L2 工具success/error→ L3 SSE/UI 与前缀同源。
补充用例
- 自动化以
tests/test_e2e_http.py、tests/test_e2e_multiturn_create_plan.py(模块级e2e,需网关/文档库的用例另标integration)为准;无test_unit.py。 - E2E HTTP/SSE 共用
test_e2e_http._post_send_message:E2E_SEND_MESSAGE_COOLDOWN_SEC(默认 0.5) 在每次POST /send-message前休眠;若 SSE 出现 可恢复类RUN_ERROR(如 2064 / 负载较高 / 429 / timeout),同一请求体最多再试 2 次(指数退避;与首次合计最多 3 次),仅测试代码,不进入 Agent 生产逻辑。 - 多轮「问候 → list_plans → create_plan → confirm」全链路单独文件
test_e2e_multiturn_create_plan.py,轮与轮之间另有_TURN_GAP_SEC(当前 1s),减轻上游连接触顶。
2.2 RDB 网关鉴权失败(401)
根因
- why1:App 侧 RDB 多用 用户 access token;Agent 侧用 API Key / Access Key 作
Bearer,两条链独立,前端能查库不能推断 Agent 侧 Bearer 是否有效。 - why2:云托管 环境变量未注入或与本地/控制台不一致 时,网关直接 401,与具体 SQL 无关。
- why3:占位
user_id主要影响行归属;401 仍以 Bearer 是否被网关接受为主(与 2.4 区分)。
方案
- 短期方案:控制台密钥具备 RDB REST 权限;云托管
CLOUDBASE_APIKEY/CLOUDBASE_ACCESS_KEY/CLOUDBASE_ACCESS_TOKEN、CLOUDBASE_ENV与本地/控制台一致。
优点:成本低。缺点:密钥轮换要同步环境。
- 长远方案:Agent 经云函数或票据换用户态写 RDB,或与行级安全策略统一。
优点:身份一致。缺点:链路更长。
采纳方案
- 排障以 短期方案 为准;本地同 Key 已对
.../v1/rdb/rest/plans验证 200 时,线上仍 401 优先查 部署环境变量。
补充用例
tests/TESTCASES.md;本地探针勿将密钥写入仓库。
2.3 会话文档库写入/读出异常(load_session found=False)
根因
- why1:首轮轮询或 save 尚未完成 时
load_session本就可能found=False,需与「已写入仍读不到」区分。 - why2:Open API 推断成功若过宽,可能出现 PUT 无明确 upsert 标志仍当 OK(假成功)。
- why3:GET 解析与真实
data形态不一致时,会得到空列表 → 误判未找到。 - why4(已缓解):会话键不一致(如图里
session_id未与threadId对齐)会导致写错桶;当前预处理会从thread_id+state.session_id解析并 注入state.session_id(不再依赖forwardedProps.session_id等旧字段)。
方案
- 短期方案:
session_persistence增强日志(response摘要、inferred_success_empty_data_flags、MISS empty_list等);先靠日志区分 G1/G2 与「首读即空」的正常情况。
优点:见效快。缺点:需拉镜像日志。
- 长远方案:收紧 upsert 成功判定、save 后 read-after-write、解析层与官方 Open API 文档对齐。
优点:假成功与解析漂移可根治。缺点:要验证多环境响应形态。
采纳方案
- 已采纳:短期日志 +
threadId→thread_id与state.session_id统一注入(见app.py_confirm_request_preprocessor)。 - 待线上验证:若 PUT 已有明确标志而 GET 仍
MISS,按日志收敛 G2 并改解析;若出现空标志仍推断成功,则收紧 + 可选 read-after-write。
补充用例
scripts/verify_nosql_session_write.py;集成测test_llm_create_plan_confirm_after_process_restart_restores_from_nosql(仍在test_e2e_http.py,integration)。
2.4 用户 ID 为占位 default(网关 userId 未进工具层)
根因
- why1:网关 AiBot 把登录用户放在 顶层
userId/forwardedProps.userId,若未写入 LangGraphstate.user_id且未调用set_user,tools.get_user_id()仍回落default或环境变量。 - why2:与真实用户不一致时,RDB 查询 行归属错误 或结果为空,表现常在 403/空列表,易与 2.2 混淆。
- why3:若仍接受
user_id蛇形、forwardedProps.user_id等旧字段,合同混乱、线上难对齐;已 废弃 该兼容路径。
方案
- 短期方案:预处理
_first_nonempty_str(state.user_id, input_data.userId, forwarded.userId),写入state["user_id"]并set_user(user_id);App/context仅description: "userId"(小写比较为userid)。会话仅threadId→thread_id与state.session_id。
优点:与网关字段一致、可测。缺点:旧客户端若只发 user_id 将不再生效。
- 长远方案:网关/SDK 直接把用户写入图初始 state,减少双源(body + state)。
优点:更少分支。缺点:依赖平台演进。
采纳方案
- 已采纳:
app.py上述解析与注入 +agent.set_user;zhiyuxing-appagent-chat.ts与confirm_payloads/ E2E 已统一userId+context.userId。
补充用例
- 日志关键字
[diag-2.4];tests/test_e2e_http.py请求体结构。
2.5 工具文案与 success 不一致([工具成功] + success: false)
根因
- why1:
execute_tools_node曾在 invoke 未抛异常时 写死record["success"] = True,未读工具返回的success。 - why2:长期仍把
success/结果 塞进「人类可读前缀 + 嵌入式 JSON」字符串流,解析与展示无法与事实单一真源,矛盾会以别种形式再现。
方案
- 短期方案:以工具返回 dict 的
success为准更新record,文案生成与之一致。
优点:立刻消除矛盾。缺点:仍依赖前缀+文本串联协议。
- 长远方案:工具结果走 独立 SSE/AG-UI 结构化事件(如
tool_result/payload),含success、message、error、业务字段;UI 只读结构化字段,不再从[工具成功]等前缀推断。
优点:可机读、可断言、与 L2/L3 同源。缺点:需 Agent 流式协议与 App 渲染同步改。
采纳方案
- 采纳长远方案(目标架构);过渡:已落地短期对齐(
execute_tools_node+_format_tool_execution_message,见 2.1 L3),在结构化事件上线前保证前缀与success一致。
补充用例
- 过渡:依赖
test_e2e_http.py/test_e2e_multiturn_create_plan.py与人工/日志核对 2.1 行为;长远:需在 结构化事件契约 稳定后加强 E2E(断言事件体而非全文前缀)。
2.6 确认结果前端 JSON.parse 失败
根因
- why1:多段
[工具成功/失败] ...与 JSON 片段拼接,整体 不是单一 JSON 文档。 - why2:在 单一文本通道 里混排说明性句子与机器数据,前端只能 启发式切分,边界变动即
JSON.parse失败。
方案
- 短期方案:前端按行/前缀切分后再解析 JSON。
优点:兼容当前流式文本。缺点:协议松散、易碎。
- 长远方案:与 2.5 一致——SSE 专用结构化事件:工具结果、确认状态、错误码
JSON一次解析;可选保留 纯文本delta仅给人读,与payload互不嵌套拼接。
优点:可机读、可断言、根除多段拼包。缺点:需 Agent 与 App 同步改。
采纳方案
- 采纳长远方案;短期切分逻辑仅作 过渡兼容(如
displayParseFallback相关路径),上线结构化事件后应 废弃对混排整包JSON.parse的依赖。
补充用例
- 过渡:App
agent-chat中与displayParseFallback相关的日志;长远:对 工具结果/确认 事件体的单测或契约测(待协议定稿后补齐)。
2.7 TOOL_CALL_RESULT SSE 缺 payload(前端报「须 schemaVersion=1、kind=…、displayText」)
现象与证据
- App
agent-chat消费 SSE 时,对TOOL_CALL_RESULT要求payload为机读结构:schemaVersion === 1、kind === 'tool_execution_result'、displayText为字符串;否则记runError,提示TOOL_CALL_RESULT 缺少 payload(须 schemaVersion=1、kind=tool_execution_result、displayText)。 - 客户端日志可见:事件
keys仅含messageId/toolCallId/content/role,无payload(如hasPayload: false);而同一次运行里,云托管diag-2.5仍可能显示 ToolMessage 上has_payload=True、agui_kind='tool_execution_result'。
根因
- why1:
LangGraph/execute_tools已把结构化结果写入ToolMessage.additional_kwargs.agui_tool_payload,并经ag_ui_dict_tool_patch等补到ToolCallResultEvent.payload(Agent 流内侧完整)。 - why2:
cloudbase_agent.server.send_message.handler在转发TOOL_CALL_RESULT时 重新构造ToolCallResultEvent,只传入tool_call_id/content/message_id/role,未传入payload,网关对外 SSE 因而 丢掉机读字段。 - why3:与「模型一轮多个
tool_call、或TOOL_CALL_*事件条数」无必然关系;单工具list_plans也会复现——只要经上述 handler 转发即丢payload。 - why4(补丁易踩坑):
send_message/server.py使用from .handler import handler,create_adapter→create_sse_stream里调用的是 server 模块全局里的handler引用,与handler.py模块属性handler不是同一绑定。若猴子补丁 只赋值cloudbase_agent.server.send_message.handler.handler、未同步cloudbase_agent.server.send_message.server.handler,则启动日志仍可能打印「已 patched」,实际跑的还是 SDK 旧函数,客户端继续 无payload;云上也不会出现[send_message_handler_payload] TOOL_CALL_RESULT ...这类逐条转发日志。
方案
- 短期方案:启动时猴子补丁 同时 替换
handler.handler与server.handler;在TOOL_CALL_RESULT分支用model_copy(update={"payload": _pl})原样转发payload,并打[send_message_handler_payload]日志(has_payload/kind/schemaVersion)。
优点:不 fork 整个 SDK、与当前 requirements 锁定版本对齐。缺点:上游修复后需删补丁,以免双维护。
- 长远方案:向 cloudbase-agent-server 合入单行修复(转发时带上
payload),升级依赖后 移除本地覆盖文件。
采纳方案
- 已采纳:
shenmiren-agent/send_message_handler_payload_forward.py(由包内handler.py衍生,仅改该分支)+app.py调用apply_send_message_tool_result_payload_forward()(在ensure_ag_ui_dict_tool_patch等之后);*apply_内必须写回server.handler**(见 why4)。
补充用例
- 证据:
zhiyuxing-app控制台[agent-chat][sse.toolResult.invalid]与云上[send_message_handler_payload] TOOL_CALL_RESULT ... has_payload=true对照;集成场景见assert_tool_call_results_have_agui_payload(定义在test_e2e_http.py,多用例复用)。 - 单测:曾用
TestSendMessageHandlerPayloadPatch防「只 patchhandler模块、未同步server.handler」的回归;该文件已删。线上/本地以app.py启动日志中含patched handler (send_message.handler + send_message.server.handler)与上述 E2E/日志交叉验证为准。
2.8 上游 LLM 限流(如 2064)与持久化排障边界
现象:集成测或真机偶发 RUN_ERROR,正文含 APIError ... (2064)「当前服务集群负载较高」 等;同一 threadId 多轮连续 send-message 时更易出现。
根因
- why1:意图识别在单次请求内对
OPENAI_BASE_URL/OPENAI_MODEL(如 MiniMax OpenAI 兼容)调用ChatOpenAI.invoke;返回码与文案来自 模型供应商,不是 CloudBase 文档库或 RDB 网关报错。 - why2:生产 Agent 侧若加固定 sleep/自动重试,会拉长所有用户首包、且可能与「同 thread 重放」语义纠缠;故 保持单次
invoke、无生产内重试(与当前仓库一致)。 - why3:与 2.3 区分:2064 不会产生「写入
waiting_confirmation后读不回」;若整轮在intent即失败,persistence_node可能根本不会执行,勿仅按 NoSQL 会话键排查。
实际采取
- 测试:见 2.1 补充用例 —
_post_send_message冷却 + RUN_ERROR 条件重试、test_e2e_multiturn_create_plan轮间间隔。 - 运维:控制台/供应商 配额与并发、错峰压测;可选更换 *
OPENAI_** 线路。 - 部署:云托管镜像更新后,环境变量仍以控制台为准(
TENCENT_SECRET_、CLOUDBASE_、OPENAI_*等);与本地 persistence 行为一致依赖 同一 env id 与密钥权限。
2.9 写操作确认路径与「客户端工具」空列表
现状:client_tools.py 中 CLIENT_TOOLS 已为空;模型 仅绑定服务端工具 schema(由 App body.tools 传入)。show_toast / navigate_to_page 等不再作为可调用工具。
与持久化的关系
- 不变:需确认的
create_plan等 仍走tool_call→persistence_node→status: waiting_confirmation、pending_tool_calls写入文档库(见README/session_persistence);用户侧仍通过 App 对 pending 列表点「执行」,再经request_preprocessor(confirm=true)→confirm_recovery_node→execute_tools。 - 变(与 2.10 对齐):
persistence_node合并回图状态时须保留needs_confirmation: true(仅pending_tool_calls清空,避免重复进 persistence),否则STATE_SNAPSHOT无待确认标志,App 不弹确认条(见zhiyuxing-app/src/lib/agent-chat.ts对needs_confirmation的注释)。模型 不能再调「单独客户端确认工具」;澄清与选项放在 助手自然语言 中。agent_sessions/agent_tool_confirmations字段语义不因此改变。
2.10 待确认已写入 NoSQL 却同轮执行写库 + 前端不展示确认
产品侧完整叙事(含「未确认写库 / 无确认条 / 标题整句」及 agents.md 式小节结构):见《开发记录博客/经典ReAct Agent Loop.md》§8。
现象(证据向)
- 用户未点确认,同一轮
send-message内 RDB 已出现create_plan落库;或云端日志可见persistence_node写完waiting_confirmation后紧接execute_tools成功。 - App 侧
needsConfirmation恒为 false:SSESTATE_SNAPSHOT未带needs_confirmation: true(客户端仅在快照显式带该字段时置位)。
根因(证据级链)
- why1:LangGraph 编译结果里
persistence同时有出边 →response与intent_recognition(agent/graph.py曾同时add_edge("persistence", "response")与add_edge("persistence", "intent_recognition")),同一 superstep 内 两后继均可被调度。 - why2:
persistence_node将文档库status: waiting_confirmation与pending_tool_calls写毕后,并行跑到的intent_recognition_node读库 看到waiting_confirmation,原逻辑将pending_tool_calls直接映射为execute_confirmed,路由进execute_tools,绕过用户确认。 - why3:
persistence_node返回needs_confirmation: False时,合并后的图状态在response/SSE 快照 中 不再体现「待确认」,与 App 契约不一致 → 确认条不显示。 - why4(加剧误执行):
intent_recognition_node中另有一段needs_confirmation and pending_tool_calls→ 直接execute_confirmed,在状态机异常叠合时也会 跳过 persistence/App 确认(已与主因一并删除)。
方案(每个问题不超过两个)
- 短期方案:① 删除
persistence → intent_recognition单边,仅persistence → response,本回合在响应结束,确认由 下一轮confirm=true进入confirm_recovery。②persistence_node返回needs_confirmation: True(与清空pending_tool_calls并存)。③ 移除 intent 内「读 DB waiting_confirmation 即当已确认」及上述needs_confirmation ∧ pending直执行分支,确认只走confirm_recovery+ 预处理注入。
优点:改动面小、与 App/集成测契约一致。缺点:依赖「确认必须带 confirm + 标准 messages」的合同不破坏。
- 长远方案:图结构上用 显式条件边 表达「仅 stage_mutating 后才回 intent」等分支,避免同节点多无条件出边语义含糊;或用 单一后继 + reducer 合并写库与 SSE 状态。
优点:拓扑自解释、减少并行歧义。缺点:要梳理所有 persistence 入边场景。
采纳方案
- 已采纳短期方案(见
shenmiren-agent/agent/graph.py、persistence.py、intent.py)。 - 补充用例:
tests/test_e2e_multiturn_create_plan.py、tests/test_e2e_http.py中 创建提案轮断言last_snapshot(..., "needs_confirmation") is True;冷启动 confirm 仍覆盖test_llm_create_plan_confirm_after_process_restart_restores_from_nosql。
补充用例
- 云上:
[persistence_node] <<< OK ... waiting App confirm之后 不应立刻出现[execute_tools_node] INVOKE ... create_plan(除非同请求体为 confirm 流)。 - 客户端:
streamAgentChat/callAgentChat解析到needs_confirmation: true时应onNeedsConfirmation。
2.11(关联)执行侧 plan_title 与用户整句不一致
说明:与 NoSQL 会话字段无直接关系,但用户在同一类链路里反馈「库中标题为整句口令」。根因:模型把 plan_title 填成整句,原 enrich_create_plan_calls_from_messages 在已有 plan_title 时不再改写。采纳:在 execute_tools 前对 create_plan 做 normalize_plan_title_for_create(agent/tool_calls_util.py);用例见 tests/test_plan_title_normalize.py。
3. 小结
- 持久化:确认恢复依赖文档库 真实可读回;首帧
found=False很多是正常现象,真问题是 写入标志异常或 GET 解析漂移,靠session_persistence新日志 与云上对照排查。 - 会话键:
threadId+state.session_id由预处理注入图状态,避免写unknown桶。 - 用户键:仅 HTTP/网关层的
userId(及图内state.user_id),set_user与 state 注入 对齐工具层;不再兼容forwardedProps.user_id/input_data.user_id/context.description: user_id。 - 鉴权:Agent Bearer 与 App 用户 token 分立;401 优先查云托管环境变量与控制台 Key 权限。
- 错误 / 工具结果:坚持分层、结构化、不可静默;2.5 / 2.6 以 SSE 结构化事件(与纯文本分流) 为长期采纳,当前
success对齐 + 前端切分 仅为过渡。 - SSE
TOOL_CALL_RESULT:2.7 — SDK handler 丢payload+ 仅 patch 模块而未同步server.handler则补丁不生效;补丁需双向绑定,并以[send_message_handler_payload] TOOL_CALL_RESULT与diag-2.5、ApphasPayload交叉验证。 - 上游 LLM(2064 等):2.8 — 供应商限流与 NoSQL 会话读写 无关;生产 不重试;E2E/多轮间隔 仅缓解测试稳定性。
- 客户端工具:2.9 —
CLIENT_TOOLS为空;pending → 确认 → 执行 的持久化链路仍只围绕 服务端工具 与 App 确认 UI。 - 确认条 / 同轮偷跑(2.10):禁止
persistence双出边进intent;persistence合并状态须needs_confirmation: true;不在intent里据waiting_confirmation自动执行 — 与docs/开发记录博客/agents.md所列「根因链 → 方案 → 采纳 → 用例」写法一致,便于复查。 - 计划标题(2.11):执行前规范化
plan_title,避免整句用户话术入 RDB(见tool_calls_util.normalize_plan_title_for_create)。
参考:shenmiren-agent/README.md、shenmiren-agent/tests/TESTCASES.md、docs/开发记录博客/agents.md(博客结构)、根目录 AGENTS.md(协作规则)、.agents/skills/http-api-cloudbase/SKILL.md。