conversation-message-flow.md 41 KB

对话消息、Agent worker 与前端展示流程

代码依据:2026-09-30 当前工作区文件。主流程使用消费者 H5 的 chat 页面,人工客服流程见第 8 节。图中的函数名、事件名、表名与状态名均取自当前代码。

1. 一次普通对话的调用顺序

sequenceDiagram
    autonumber
    actor User as 客户
    participant UI as H5 chat 页面
    participant API as FastAPI
    participant DB as MySQL
    participant Worker as Agent worker
    participant Redis as Redis state
    participant Model as 模型服务
    UI->>API: 查询或创建会话,读取历史和当前运行
    API->>DB: 查询 conversations / conversation_events / agent_runs
    API-->>UI: 会话状态、消息历史、当前运行
    UI->>API: GET /conversations/{id}/events,Last-Event-ID
    API-->>UI: SSE connected 注释,保持连接
    User->>UI: 输入消息并点击发送
    UI->>API: POST /conversations/{id}/messages<br/>text + expected_control_version + Idempotency-Key
    API->>DB: 开始事务,校验身份并锁定会话
    API->>DB: 检查幂等键、控制版本与活动运行
    API->>DB: INSERT agent_runs,status=QUEUED
    API->>DB: UPDATE conversations.active_run_id 和 sequence
    API->>DB: INSERT conversation_events,type=message.user
    API->>DB: 提交事务
    API-->>UI: HTTP 201,EventView,payload.run_id
    Note over UI: 返回序号较新时保存运行标识,清空输入<br/>消息列表由服务端事件更新
    Worker->>DB: execute_one → reconcile → claim<br/>查询任务,锁定租户、会话与运行
    Worker->>Redis: acquire 申请模型并发额度
    Redis-->>Worker: 额度准入结果
    Worker->>DB: RUNNING,lease_token + 1<br/>lease_until=当前时间+125秒,提交事务
    Worker->>DB: load → model<br/>保存 ModelAttempt 与 agent.model_started
    Worker->>DB: BudgetCallback 预占 token<br/>已配置 MoneyPolicy 时预占金额
    Worker->>Model: ChatOpenAI.bind_tools(...).ainvoke(...)
    Model-->>Worker: 完整 AIMessage 与 usage
    Worker->>DB: 预算结算;独立保存 ModelAttempt 用量
    opt 模型要求调用工具
        Worker->>DB: tools / followup 执行业务并保存 ToolMessage
        Worker->>DB: 保存 agent.tools_completed 与 next_node
        Worker->>Model: 使用工具结果继续模型调用
        Model-->>Worker: 完整最终答复与 usage
    end
    Worker->>DB: 校验租约、权限、控制权、引用与记忆版本
    Worker->>DB: 同一事务保存 COMPLETED 和 message.assistant<br/>清除 active_run_id,提交事务
    Worker->>Redis: finally 释放并发额度
    loop SSE 每轮查询后等待0.5秒
        API->>DB: read_events 查询 sequence 大于当前游标的事件
        DB-->>API: 已提交事件,每批最多50条
        API-->>UI: SSE id / event / data
    end
    UI->>UI: receiveEvent 按序号去重、核验控制版本、更新状态
    UI->>UI: shared.messages → messageItems → Vue 消息气泡
    UI-->>User: 显示完整答复和原始消息时间

图中接口路径省略统一前缀 /openapi/v1。SSE 从建立连接后便持续读取,客户消息和运行事件可以在执行过程中先后到达。数据库提交后,HTTP 响应、worker 领取和 SSE 读取由各自进程调度,三者的先后顺序没有固定保证。

普通成功运行通过 message.assistant 提交最终答复,同时数据库状态变为 COMPLETED。前端据此清除当前活动运行并展示正文。

代码入口:customer.js:254、conversations/api.py:185、worker.py:137、runtime.py:284、stream.py:131。

2. 前端如何建立会话并发送消息

flowchart TD
    A[进入消费者 chat 页面] --> B[loadPage 建立本次页面 generation]
    B --> C[ensureConversation]
    C --> D{已有 conversationId?}
    D -->|已有| E[GET 当前会话]
    D -->|没有| F[GET 当前店铺本人会话列表]
    F --> G{列表存在会话?}
    G -->|存在| H[使用返回列表第一项]
    G -->|没有| I[POST 创建会话,保存聊天与控制状态]
    E --> J[保存 conversation 和 control_owner]
    H --> J
    I --> J
    J --> K[refreshMessages 按 after 读取历史<br/>每页100条,按 sequence 去重]
    K --> L[重新读取会话<br/>存在 active_run_id 时读取运行]
    L --> M[读取人工协助状态<br/>connectMessages 建立 SSE]
    M --> N[connected=true]
    N --> O[send 检查登录、输入、连接和 busy]
    O --> P{存在活动运行?}
    P -->|没有| Q[ensureConversation 获取当前控制版本]
    P -->|等待 clarification| Q
    P -->|其他活动状态| S[发送入口停止本次操作]
    Q --> T[组合 aiContext 与问题文本<br/>检查总长度不超过4000字符]
    T --> Clarify{正在等待 clarification?}
    Clarify -->|是的| R[POST /agent/runs/id/inputs<br/>等待标识、运行版本、输入框回答]
    Clarify -->|其他情况| U[生成或复用本次消息 Idempotency-Key]
    U --> V[api.send → api.request → fetch POST]
    V --> W[HTTP 201 返回 EventView]
    R --> X[HTTP 202 返回输入收据和原运行状态]
    W --> Y[存在 run_id 且返回序号较新时更新 active_run_id<br/>移除本次页面幂等键]
    X --> InputResult[interrupt_id 仍匹配时更新 activeRun<br/>移除本次输入幂等键]
    InputResult --> Z[清空输入,showLatestMessages<br/>SSE 事件到达后展示消息]
    Y --> Z

ensureConversation() 使用已保存的会话 ID;没有 ID 时使用列表第一项,列表为空才创建。后端会话列表按照 UUID 升序返回。consult(context) 从商品或订单入口保存 state.aiContext,然后进入 #/customer/chat。

请求位置 字段 用途与约束
Header Authorization: Bearer ... 消费者当前会话令牌
Header Idempotency-Key 1~64 字符;同一次提交结果未知时复用原键
JSON text 后端限制 1~4000 字符;页面会加入商品或订单咨询上下文
JSON expected_control_version 最近读取的会话控制版本,至少为 1

页面输入控件最多 2000 字符,加入咨询对象后再次检查 4000 字符上限。send() 成功后更新运行标识并清空输入,服务端事件负责向 shared.messages 添加消息。result.sequence > state.after 检查处理 SSE 提前送达的情况。

页面的 key(action, body) 将幂等键保存在 state.commandKeys。普通 HTTP 请求默认超时为 20 秒;网络结果未知时保留页面键,客户重试相同提交时继续使用。createApi.request() 同时通过 pendingKeys 管理请求键,页面显式传入的键优先生效。

代码:会话与历史读取、页面加载、消费者接口映射、HTTP 客户端。

3. 后端如何将消息与任务一起入库

flowchart TD
    A[POST messages<br/>FastAPI 校验 Message 与 Idempotency-Key] --> B[sessions.begin 开始事务]
    B --> C[authenticate 校验令牌、租户、账户<br/>access 锁定并检查会话访问权限]
    C --> D[accept_message 计算请求 SHA-256]
    D --> E[prior 查询同会话、同操作者、同键的事件]
    E --> F{已有事件?}
    F -->|同摘要| G[返回原 EventView]
    F -->|摘要不同| X[抛出 BusinessError<br/>事务撤销本次未提交修改]
    F -->|没有| H[检查 expected_control_version<br/>客服还需已接管且为 assigned_staff_id]
    H --> I{CUSTOMER 且 control_owner=AGENT?}
    I -->|符合| J[确认 active_run_id 为空<br/>读取最近12条历史消息<br/>连同本次文本限制24000字符]
    J --> K[queue_run 读取发布版本和模型配置<br/>检查账户活动任务少于3个]
    K --> L[session.add AgentRun<br/>session.flush 执行 INSERT,status=QUEUED]
    L --> M[设置 run.conversation_id、control_version<br/>设置 conversation.active_run_id]
    I -->|客户由人工接待或客服回复| N[run_id=null]
    M --> O[emit:conversation.sequence + 1<br/>session.add ConversationEvent]
    N --> O
    O --> P[客户消息按授权情况添加 MemoryJob<br/>已分配客服时添加通知]
    P --> Q[构造 EventView]
    Q --> R[正常退出 sessions.begin<br/>flush 剩余变更并 COMMIT]
    G --> R
    R --> S[HTTP 201 返回持久事件]
    H -. 校验失败 .-> X
    J -. 存在活动运行 .-> X
    K -. 配置或数量不符合 .-> X

message() 将这些操作放在同一个 async with sessions.begin() 中。session.add() 把对象交给当前 SQLAlchemy Session;flush() 将 SQL 发送给数据库,事务在正常退出上下文时提交。任何一步抛出异常,本次事务中尚未提交的修改一起撤销。

queue_run() 中的 flush() 发生在调用方设置 conversation_id 和 control_version 之前。运行插入、后续关联字段、会话活动标识和用户事件最终一起提交。worker 和 SSE 使用各自 Session 查询已提交数据;数据库连接使用 READ COMMITTED。

数据表 本次写入或更新 后续读取方
conversations 保存客户、店铺和控制状态;active_run_id 指向运行;sequence 分配下一个事件序号 页面、worker、SSE
conversation_events message.user 或 message.staff;payload={text, run_id};请求键、摘要、控制版本、创建时间 历史接口、SSE、下一次对话上下文
agent_runs 客户处于 AGENT 模式时创建:QUEUED/model、上下文、调用身份、模型参数、发布快照、期限 Agent worker
memory_jobs 已授权记忆的客户消息创建任务,关联会话与事件序号 memory worker
notifications 已分配客服时,由 notify() 为仍有权限的员工保存客户消息通知 商家通知接口

conversation_events 保存页面可读取的会话事件;agent_runs.messages 保存 LangChain 格式的模型输入、助手工具调用和工具结果。

  • authenticate() 校验 AccessSession、租户与账户状态;写入请求锁定操作者记录。access() 锁定会话,校验客户本人或客服的店铺权限。
  • prior() 位于控制版本检查之前。同键同内容返回原事件;同键不同内容返回 409 IDEMPOTENCY_CONFLICT。
  • 会话行锁保护 active_run_id 与 sequence。同一会话已有活动运行时返回 409 RUN_IN_PROGRESS。
  • 最近最多 12 条历史消息恢复正序后追加当前消息,正文合计最多 24000 字符。客户历史使用 HumanMessage,助手及客服历史使用 AIMessage。
  • queue_run() 在接受新运行时读取店铺当前发布,已有会话的新运行也使用此时的配置。模型名、地址、凭据环境变量名、超时、输出限制、提示词及 graph_version 保存在运行快照中。进行中的运行、补充输入、审批等待及进程恢复继续使用原运行快照。当前代码登记的最新图版本为 after-sales-v10。
  • 新运行期限为 10 分钟。账户中未过期的 QUEUED、RUNNING、WAITING_USER、WAITING_APPROVAL 合计达到 3 个时,返回 429 AGENT_QUEUE_FULL。

代码:message 与 accept_message、queue_run、emit、会话数据模型、运行数据模型、数据库连接。

4. worker 如何启动、领取和循环处理任务

容器 agent 服务使用 python -m zhifu.operations.worker agent。开发环境也支持 python -m zhifu.modules.agent_runtime.worker;该入口提供 --once,执行一次领取和处理后退出。

flowchart TD
    A[operations.worker.run agent] --> B[登记停止信号<br/>进入 run_monitored]
    B --> C[heartbeat agent<br/>每5秒写 Redis,记录20秒有效]
    C --> D[启动 agent_runtime.worker.main 任务<br/>读取 Settings 与 AgentSettings]
    D --> E[创建数据库 engine 和 sessions<br/>连接 Redis state<br/>建立知识检索 HTTP 与 Milvus 客户端]
    E --> F[execute_one]
    F --> G[reconcile 核对等待中的业务运行<br/>每轮最多100个]
    G --> H[生成 capacity_id<br/>10秒期限内执行 claim]
    H --> I[开始领取事务<br/>锁定 CapacityPolicy 并读取租户预留额度]
    I --> J[筛选存在任务且未满并发的租户<br/>优先未用完预留额度的租户<br/>随后按 selected_at 与 Tenant.id 排序]
    J --> K{取得租户且重新检查额度通过?}
    K -->|没有通过| N[本次没有领取任务]
    K -->|通过| L[更新 tenant_dispatch.selected_at<br/>按 created_at、id 选择任务]
    L --> M[锁定 Conversation<br/>重新锁定并检查 AgentRun]
    M --> O{任务已超过 deadline_at?}
    O -->|已经超过| P[FAILED,RUN_DEADLINE<br/>finish_run 保存失败事件并释放会话]
    P --> N
    O -->|仍然有效| Q[Redis acquire 原子检查<br/>全局、交互、后台及租户预留额度]
    Q --> R{取得并发额度?}
    R -->|没有取得| N
    R -->|取得| S[旧 STARTED 尝试改为 UNKNOWN<br/>RUNNING,lease_token + 1<br/>lease_until=当前时间+125秒]
    S --> T[提交领取事务<br/>返回 run_id 与 lease_token]
    T --> U[核对旧模型尝试的预算<br/>build_graph,执行期限120秒]
    U --> V[finally 释放 Redis 并发额度]
    N --> V
    V --> W{本次是否处理运行?}
    W -->|已经处理| F
    W -->|没有处理| X[等待0.5秒]
    X --> F
    B -. 收到停止信号 .-> Y[取消业务任务并等待清理<br/>关闭客户端,删除进程心跳]

claim() 接受三类候选:

  1. QUEUED:尚待执行或重新排队的运行。
  2. RUNNING 且 lease_until < now():执行租约已经过期的运行。
  3. WAITING_USER、WAITING_APPROVAL 且 deadline_at <= now():需要结束的过期等待运行。

领取使用 MySQL 行锁协调多个 worker,在取得租户锁后重新统计仍有有效租约的运行数量。默认租户并发为 2,有 TenantQuota.interactive_limit 时使用该租户配置。全局模型并发默认 4,交互预留默认 2,后台预留默认 1;具体部署值由配置确定。

Redis acquire() 使用 Lua 一次检查并更新 agent:active:global、:interactive、:background 与 :owners。额度记录有效期为 150 秒;execute_one() 的 finally 主动释放本次 capacity_id。Redis 更新和 MySQL 提交各自执行,释放逻辑和有效期限负责退出后的额度清理。

每个 main() 循环等待当前 execute_one() 完成后才领取下一项。因此单个进程一次执行一个运行,多个进程通过共同的数据库记录和 Redis 额度协调并发。

heartbeat() 的 Redis 记录表示进程存活。AgentRun.lease_until 是单次领取的数据库执行期限,当前实现领取时设置一次。每次领取增加 lease_token,节点提交必须携带相同 token;旧进程的迟到结果无法通过租约检查。

图中的循环表示正常处理路径。一般执行异常会在记录运行失败后继续抛出,结束当前进程;预算准入错误记录失败后允许循环继续。容器配置使用 restart: unless-stopped。

代码:容器配置、统一进程入口、进程心跳、claim、execute_one、main、并发额度。

5. worker 如何执行模型、工具和最终提交

flowchart TD
    A[graph.ainvoke<br/>run_id、token、recursion_limit=24] --> B[load<br/>locked_run 后读取数据库 next_node]
    B --> C{next_node}
    C -->|model| D[事务一:locked_run<br/>核验引用与记忆快照<br/>检查模型次数、step 与上下文长度]
    D --> E[保存 STARTED ModelAttempt<br/>model_calls + 1<br/>emit agent.model_started,提交事务]
    E --> F[事务外读取已授权记忆<br/>构造 ChatOpenAI 与工具定义]
    F --> G[BudgetCallback.on_chat_model_start<br/>预占 token;配置 MoneyPolicy 时预占金额]
    G --> H[模型网络调用<br/>max_retries=0,返回完整 AIMessage]
    H --> I[预算回调处理 usage<br/>独立事务保存 ModelAttempt RECEIVED 与 usage]
    I --> J[事务二:重新 locked_run<br/>检查引用、记忆版本和工具参数]
    J --> K[追加 AIMessage,step + 1]
    K --> L{存在 tool_calls?}
    L -->|没有| M[COMPLETED,next_node=done<br/>version + 1,清除 lease_until]
    M --> N[finish_run<br/>emit message.assistant<br/>清除 conversation.active_run_id]
    N --> O[提交事务,图执行结束]
    L -->|存在| P[保存 next_node=tools 并提交]
    P --> Q[工具节点:检查工具名称和参数<br/>每轮最多4个工具]
    C -->|tools| Q
    Q --> R{工具种类}
    R -->|search_knowledge| S[事务外查询知识服务<br/>事务内再次校验结果权限和版本]
    R -->|普通业务工具| T[invoke_tool<br/>执行业务检查与读写]
    R -->|request_clarification| U[pause_for_input 保存等待事件<br/>随后保存 ToolMessage 和 step]
    R -->|create_exchange_proposal| V[创建方案;保存 ToolMessage<br/>step 加一]
    R -->|create_followup_request| W[先提交 next_node=followup]
    C -->|followup| W2[followup 节点执行业务登记]
    W --> W2
    W2 --> X[同一事务保存业务收据、通知、ToolMessage<br/>next_node=model,emit agent.tools_completed]
    S --> Y[保存 ToolMessage,step + 1<br/>next_node=model,emit agent.tools_completed]
    T --> Y
    X --> D
    Y --> D
    U --> Z[追加助手问题;emit agent.tools_completed<br/>WAITING_USER 或 WAITING_APPROVAL,next_node=wait]
    V --> BusinessWait{发布版本启用 business_wait?}
    BusinessWait -->|已经启用| PauseBusiness[pause_for_business 保存等待事件]
    PauseBusiness --> Z
    BusinessWait -->|没有启用| ContinueModel[next_node=model<br/>emit agent.tools_completed]
    ContinueModel --> D
    Z --> Z2[提交事务,图执行结束<br/>保留活动运行等待后续输入或业务事件]
    C -->|done 或 wait| Z3[图执行结束]

build_graph() 先进入 load,按照数据库中的 run.next_node 继续。工具执行结果、step、next_node 和模型上下文保存在 MySQL,重新领取后继续读取这些记录。

locked_run() 在关键节点读取与结果提交时检查:运行身份有效、租户和账户相符、店铺有效、权限有效、会话由 AGENT 控制、控制版本一致、活动运行相同、状态为 RUNNING、token 相同、租约和任务期限均未到期。

模型调用的提交位置:

  1. 调用前事务写入 ModelAttempt(status=STARTED) 和 agent.model_started,增加持久化的 model_calls。
  2. 模型请求在业务事务外执行;BudgetCallback 在发送前预占 token,租户配置 MoneyPolicy 时同时预占金额,返回后处理真实 usage。
  3. 独立事务保存 ModelAttempt(status=RECEIVED, usage=...),保留已经发生的用量。
  4. 重新获得运行锁并通过校验后,保存模型消息和后续节点。最终答复、COMPLETED 和清除 active_run_id 在同一事务提交。

普通工具由 invoke_tool() 调用业务服务。工具命令标识为 agent:{UUID(bytes=run.id).hex}:{run.step}:{index},相同持久化步骤重新执行时保持一致,供业务服务核验幂等收据。request_clarification 必须单独调用;create_followup_request 也必须单独调用,并使用 followup 节点。方案等待取决于 GRAPH_RELEASES[run.graph_version]["business_wait"];关闭此能力的历史发布版本按普通工具路径继续模型节点。

限制 当前代码中的值
单次领取的图执行期限 120 秒
数据库执行租约 125 秒
Redis 并发额度有效期 150 秒
单次模型超时 发布快照的 model_timeout;配置默认 30 秒,上限 30 秒,并受剩余任务期限约束
模型 SDK 自动重试 max_retries=0
每个运行模型调用上限 6 次,通过 model_calls < 6 检查
节点步骤限制 通过 step < 20 检查
单轮工具数量 最多 4 个
运行上下文长度 json.dumps(run.messages, ensure_ascii=False) 最多 32000 字符
默认最大输出 1024 token;配置允许 128~4096,运行使用发布快照

代码:locked_run、工具节点、build_graph、模型节点、预算回调、finish_run。

6. SSE 如何读取数据库并让前端显示消息

flowchart TD
    A[connectMessages<br/>GET events,携带 Authorization 和 Last-Event-ID] --> B[API 认证、会话权限和游标检查]
    B --> C[Redis Lua 申请全局与账户 SSE 额度]
    C --> D[返回 text/event-stream<br/>浏览器验证响应后调用 onOpen]
    D --> E[generate 立即发送 connected 注释]
    E --> F[read_events:独立短事务<br/>重新验证身份、会话权限和游标]
    F --> G[查询 sequence 大于 cursor 的事件<br/>升序返回,每批最多50条]
    G --> H[逐项推进服务端 cursor<br/>过滤当前控制版本不适用的 Agent 事件]
    H --> I[发送 id、event 和 data]
    I --> J[fetch 正文流 → TextDecoderStream<br/>EventSourceParserStream → JSON.parse]
    J --> K[核验 sequence、id、type、control_version]
    K --> L[receiveEvent:sequence 去重<br/>更新 state.after]
    L --> M[过滤旧控制版本<br/>更新 owner、active_run_id 和等待状态]
    M --> N[shared.messages.push]
    N --> O[messageItems 生成正文或服务提示]
    O --> P[Vue 以 sequence 为 key 更新页面<br/>ExpandableText 显示正文]
    P --> Q[查看最新消息时自动滚动<br/>阅读历史时增加未读数量]
    I --> R{连接达到约50秒?}
    R -->|尚未达到| S[等待0.5秒]
    S --> F
    R -->|已经达到| T[结束响应,释放 SSE 额度]
    T --> U[浏览器正常读完后<br/>携带最新 state.after 再次订阅]
    U --> A

SSE 从 conversation_events 查询消息。worker 保存结果后,正在运行的 generate() 在后续查询中读取已提交事件,再通过 HTTP 响应正文发送给浏览器。Redis 在此流程中管理连接额度;会话事件的内容和序号保存在 MySQL。

请求路径为 GET /openapi/v1/conversations/{conversation_id}/events。初次订阅默认 Last-Event-ID=0;当前页面已经读取历史时使用其 state.after。每轮查询在独立事务中验证 token、账户、租户与会话权限,查询完成后释放数据库连接。API 使用 asyncio.Semaphore(settings.pool_size) 限制同时进行的 SSE 数据库读取。

持久事件结构如下,标识和正文仅说明字段形式。

id: 7
event: message.assistant
data: {"sequence":7,"type":"message.assistant","control_version":1,"created_at":"2026-09-30T00:00:00+00:00","payload":{"text":"已查询到订单状态。","run_id":"{run_id}"}}

id 等于 sequence,event 等于 type,data 为完整 EventView。服务端立即发送 connected 注释,每 15 秒发送心跳,约 50 秒结束连接,发送超时为 5 秒。前端单次连接总期限为 65 秒,使用 EventSourceParserStream 处理 SSE 分段与心跳,解析缓冲上限为 1 MiB。

默认全局最多 200 条 SSE 连接,每账户最多 3 条;部署可修改。Redis 连接额度有效期为 60 秒,连接结束通过 release() 清除。服务端每轮还检查续传游标:小于 stream_cursor 返回 410 EVENT_CURSOR_EXPIRED,大于会话最新 sequence 返回 422 INVALID_EVENT_CURSOR。

前端 receiveEvent() 的执行顺序为:检查 sequence <= state.after 并跳过重复事件;新事件立即更新 state.after;过滤旧控制版本;修改会话与运行状态;最后向 shared.messages 添加事件。messageItems 重新计算后,Vue 更新页面。

持久事件 前端处理与显示
message.user 从 payload.text 或 payload.answer 生成客户气泡,显示“已发送”;携带 run_id 时登记活动运行。
agent.model_started 保存进度事件;页面根据活动运行显示查询提示。该事件没有专门把本地 activeRun.status 改为 RUNNING 的分支。
agent.tools_completed 保存工具进度事件。普通消息区域根据消息类型生成正文。
message.assistant 显示完整 AI 答复;携带 waiting 时保存等待信息,普通完成答复清除适用的活动运行。
run.waiting 保存 interrupt_id、运行版本、问题及等待类型;显示补充信息或业务操作入口。
run.resumed 更新活动运行,清除等待信息;存在 proposal_id 时重新读取业务卡片。
message.staff 显示人工客服正文。
control.changed 更新控制权、控制版本、当前运行及人工协助状态,显示服务方变化提示。
handoff.requested 保存已申请人工协助状态,并显示提示。
run.failed / run.cancelled 清除适用的活动运行,显示失败或停止提示。

messageItems 将客户、助手和客服正文交给 ExpandableText;时间使用服务端事件的原始 created_at。浏览器距离消息列表底部小于 48 px 时,新消息自动滚动到最新位置;阅读历史时保留当前位置并增加未读数量。用户发送消息或点击新消息按钮时显示最新消息。

连接情况 处理规则
服务端正常结束响应 connectMessages() 自动继续循环,以最后事件序号订阅。
410 EVENT_CURSOR_EXPIRED 或流内 stream.expired 本次连接循环自动恢复一次:清空本地历史和游标,重新读取消息历史、当前会话和活动运行,再订阅。历史接口仍可读取持久事件。
401 / 403 / 404 停止连接并清理适用的当前会话内容;认证失效时清除对应登录会话。
429 SSE_LIMIT 根据 Retry-After 显示等待秒数,之后允许用户重新连接。
网络异常或连接超时 显示错误,用户点击“重新连接”触发页面重新加载与订阅。
页面切换或组件卸载 中止旧连接;generation / valid() 限制旧页面的请求修改当前页面。

服务端过滤 agent.* 和 message.assistant 的过期控制版本;前端实时处理另外过滤 run.* 的旧控制版本。历史读取保留原始事件及原始控制版本。

代码:SSE 查询、SSE 生成器、前端 SSE 读取、receiveEvent 与重连、气泡与滚动、页面模板。

7. 等待输入、业务确认、失败和任务恢复

stateDiagram-v2
    [*] --> QUEUED: 消息与运行提交成功
    QUEUED --> RUNNING: worker 领取,取得新 lease_token
    RUNNING --> COMPLETED: 提交完整助手答复
    RUNNING --> WAITING_USER: 保存补充问题或待确认方案
    WAITING_USER --> QUEUED: 接受 clarification 输入
    WAITING_USER --> WAITING_APPROVAL: 核对客户已确认方案
    WAITING_USER --> QUEUED: 已提交业务结果允许继续生成
    WAITING_APPROVAL --> WAITING_USER: 方案需要修订
    WAITING_APPROVAL --> QUEUED: 核对有效业务结果
    RUNNING --> RUNNING: 租约到期后重新领取
    QUEUED --> FAILED: 超过执行期限
    RUNNING --> FAILED: 执行、预算或权限检查失败
    WAITING_USER --> FAILED: 超过等待期限或权限失效
    WAITING_APPROVAL --> FAILED: 超过等待期限或权限失效
    QUEUED --> CANCELLED: 用户停止或客服接管
    RUNNING --> CANCELLED: 用户停止或客服接管
    WAITING_USER --> CANCELLED: 用户停止或客服接管
    WAITING_APPROVAL --> CANCELLED: 用户停止或客服接管
    COMPLETED --> [*]
    FAILED --> [*]
    CANCELLED --> [*]

状态转换发生在对应处理函数的身份、状态、版本和幂等检查通过以后。

补充信息。 pause_for_input() 将运行改为 WAITING_USER / wait,增加 version,清除 lease_until,等待期限设置为 24 小时。waiting 保存 interrupt_id、run_version、问题和候选信息;同事务保存带 waiting 的 message.assistant 和 run.waiting。本次 graph 结束后释放并发额度,会话保留原 active_run_id。

前端发现等待类型为 clarification 时,发送 POST /openapi/v1/agent/runs/{run_id}/inputs:

{
  "interrupt_id": "{current_interrupt_id}",
  "expected_run_version": 2,
  "answer": "客户补充的信息"
}

accept_input() 检查幂等收据、等待状态、运行版本、等待标识、期限和会话控制权。成功时追加 HumanMessage,保存 RunInputEvent,清空等待信息,将原运行恢复为 QUEUED / model,更新本次访问 token,重新设置 10 分钟执行期限;同事务保存 message.user 和 run.resumed。worker 随后领取原 run_id。

方案确认与主管审批。 pause_for_business() 保存 business_ticket_id、业务版本及等待信息。草稿和需要修订的方案进入 WAITING_USER,已提交审批的方案进入 WAITING_APPROVAL。客户确认和主管决定通过业务接口保存。每轮 worker 开始时,reconcile() 最多核对 100 个未过期的业务等待运行,验证持久业务事件、客户确认、主管权限及方案版本,再保存输入收据和更新后的等待状态,或者重新排队并保存 run.resumed。

失败记录。 graph 执行异常后,execute_one() 在独立事务中检查运行仍为 RUNNING 且 lease_token 相同,然后保存 FAILED、error_code,增加版本,清除等待与租约,并保存 run.failed、清除会话活动运行。仍为 STARTED 的尝试按情况改为 NOT_SENT 或 UNKNOWN。预算准入拒绝允许 worker 继续循环;其他执行异常继续传播。上述处理完成后,finally 释放 Redis 额度。

进程恢复。 进程异常终止后仍为 RUNNING 的记录,在执行租约到期后满足重新领取条件。新领取增加 lease_token,旧 STARTED 尝试改为 UNKNOWN 并核对预算,graph 从 MySQL 的 next_node 和 messages 继续。模型结果尚未保存时可能产生新的模型尝试,各次尝试保留独立计量记录。已经保存为 FAILED 的任务不在 claim() 的候选条件中。

停止与清理。 用户停止调用 /agent/runs/{id}/cancel,保存 CANCELLED 和 run.cancelled。统一进程入口管理停止信号与心跳;main() 的 finally 关闭 Redis 和数据库 engine,retrieval_client() 上下文关闭知识服务客户端。容器按配置处理进程重新启动。

代码:补充信息、输入 API、业务等待、业务核对、失败处理、用户停止。

8. 人工客服接管和回复如何返回消费者页面

sequenceDiagram
    autonumber
    participant Staff as 商家后台
    participant API as FastAPI
    participant DB as MySQL
    participant H5 as 消费者 H5
    Staff->>API: POST conversations/id/takeover,控制版本和幂等键
    API->>DB: 事务:权限和控制版本检查,锁定会话
    API->>DB: 取消活动运行,清除 active_run_id
    API->>DB: HUMAN,指定客服,control_version 加一
    API->>DB: 保存 control.changed,提交
    API-->>Staff: 返回控制事件
    Staff->>API: GET conversation 和 messages
    API-->>Staff: 当前控制状态与历史
    API->>DB: 消费者 SSE 读取新事件
    API-->>H5: control.changed
    H5->>API: POST messages,客户在 HUMAN 模式发送
    API->>DB: 保存 message.user,run_id 为 null,提交
    API-->>H5: HTTP 201
    API-->>H5: SSE message.user
    Staff->>API: 打开会话或点击刷新消息
    API->>DB: GET 会话状态和消息历史
    API-->>Staff: 客户消息
    Staff->>API: POST messages,客服回复
    API->>DB: 检查当前接管员工,保存 message.staff,提交
    API-->>Staff: HTTP 201
    Staff->>API: 重新读取会话与消息
    API-->>Staff: 显示已发送回复
    API->>DB: 消费者 SSE 读取 message.staff
    API-->>H5: SSE message.staff
    H5->>H5: messageItems 生成客服气泡

商家后台 control(action) 显示确认窗口,再发送 takeover 或 release。后端要求员工具备 conversations:manage;交还控制权要求当前员工是已接管客服。成功后增加 control_version 并保存 control.changed。交还后客户再次发言,按 AGENT 模式创建新运行。

客服回复要求 HUMAN、assigned_staff_id 等于当前员工,且具有 conversations:reply。回复使用同一个消息接口、幂等键和控制版本检查,保存 message.staff。模型结果提交时的 locked_run() 继续检查会话控制权和版本。

商家会话页通过 conversation() 查询详情和前 100 个历史事件,“读取后续消息”调用 moreMessages(),使用 after=messageSequence 继续。打开会话、点击刷新,以及本人回复或控制操作成功后重新读取消息。商家页面每 30 秒更新的是员工通知和未读数量;聊天内容按上述页面操作读取。

商家 presentEvent() 将客户、客服和助手正文转为消息条目,将模型开始、工具完成等信息放入“服务处理记录”。消费者 H5 持续使用第 6 节的 SSE 流程接收人工回复。

代码:后端控制权操作、商家读取、控制和回复接口、商家页面操作、商家通知定时器。

9. 接口、版本字段和事务查询位置

普通消息请求如下,标识为占位符。

POST /openapi/v1/conversations/{conversation_id}/messages
Authorization: Bearer {access_token}
Content-Type: application/json
Idempotency-Key: {same_operation_uuid}

{
  "text": "请帮我查询订单状态",
  "expected_control_version": 1
}

成功响应为 201 EventView,主要字段为 sequence、type、control_version、created_at 和 payload。payload.run_id 关联异步运行;payload.text 是已保存正文。字段校验会去除请求字符串首尾空白,并拒绝未定义字段。

接口或处理阶段 结果与处理规则
POST /conversations 客户在具备 orders:read 的店铺创建会话;初始控制方为 AGENT;幂等作用域为租户与客户。
POST /conversations/{id}/messages 成功保存消息返回 201;CONTROL_CONFLICT、RUN_IN_PROGRESS、IDEMPOTENCY_CONFLICT 返回 409。
消息受理的任务上限 账户活动任务达到上限时返回 429 AGENT_QUEUE_FULL;默认重试等待信息由服务端返回。
消息受理的模型配置检查 模型名称或地址不可用时返回 503 MODEL_NOT_CONFIGURED;发布版本检查也可能拒绝受理。
GET /conversations/{id}/messages after >= 0,limit=1–100,响应 {items, sequence};分页使用实际最后一项的序号。
GET /conversations/{id}/events SSE 通过 Bearer 认证;窗口过期返回 410,游标超过范围返回 422,连接限额返回 429。
GET /agent/runs/{id} 读取当前运行状态、版本和等待信息,并执行运行访问权限检查。
POST /agent/runs/{id}/inputs 接收等待中的补充信息,成功返回 202 和原运行更新状态;检查等待标识、运行版本和幂等键。
worker 模型预算检查 token 或适用金额预算不足、缺少价格配置时保存 run.failed;消息受理的 201 已表示客户正文保存成功。

表中接口路径统一带 /openapi/v1 前缀;身份、权限或参数不符合时返回相应 401 / 403 / 404 / 422。数据库幂等记录和版本检查由对应接口在事务中执行。

标识或字段 控制的内容
conversation_id 当前客户会话及其全部持久事件。
run_id 某次异步运行,包括后续补充信息和业务等待。
sequence 会话事件顺序,供历史分页、SSE 续传和前端去重使用。
control_version 会话控制权版本,接管和交还时增加。
AgentRun.version / run_version 运行状态版本,补充信息与业务恢复时检查。
lease_token 某次 worker 领取的执行权,重新领取时增加。
interrupt_id 当前等待步骤标识,限定输入所恢复的等待。
Idempotency-Key 同一操作重试标识,作用域和内容检查由具体接口确定。
事务边界 一起提交的主要数据
接受客户消息 新运行、会话活动运行指针、事件序号、message.user,以及适用的记忆任务和通知。
worker 领取 租户调度时间、旧模型尝试状态、RUNNING、新执行 token 及租约。Redis 额度通过独立 Lua 调用取得。
模型开始 ModelAttempt STARTED、模型调用计数、agent.model_started。
预算与用量 预算预留、结算及尝试用量使用各自短事务,保留已经发生的消耗。
工具完成 工具业务变更、ToolMessage、步骤、后续节点和适用进度或等待事件。
最终答复 完整 AIMessage、COMPLETED、运行版本、清除租约、message.assistant、清除 active_run_id。
补充信息 输入收据、上下文、原运行重新排队、客户消息、run.resumed。

查询单次对话时,可从 conversation_id 找到 conversation_events.payload.run_id,据此读取 agent_runs 和 model_attempts,再用事件 sequence 核对浏览器的 state.after。

本文依据当前源码说明执行过程。文档检查覆盖 Mermaid 解析、图形渲染和代码引用;业务接口与生成模型调用未执行。