main: 增强 Agent Run 调度可靠性与幂等性

- 默认切换 AgentProvider 为 HttpAgentProvider,增强网络请求的容错和重试机制
- 优化 Run 逻辑,支持多场景去重与并发保护
- 添加 Redis 发布失败的日志记录以提升问题排查效率
- 扩展 OpenAPI 规范,新增 Error 和 Run 状态相关模型
- 增强测试覆盖,验证调度策略和重复请求的幂等性
- 增加数据库索引以优化查询性能
- 更新所有相关文档和配置文件
This commit is contained in:
2025-12-18 17:41:42 +08:00
parent 2ad101c297
commit 6d934f4e34
16 changed files with 634 additions and 118 deletions

View File

@@ -8,6 +8,14 @@
- 2025-02-14新增 ChatSession 创建、消息追加、增量查询接口;支持状态门禁与 dedupe 幂等。
- 2025-02-14MVP-1.1 增加会话列表、会话更新(重命名/状态变更),列表附带最后一条消息摘要。
- 2025-02-15Agent Run MVP-0 —— RunDispatcher + AgentRunJob + DummyProvider自动在 user.prompt 后触发一次 Run落地 run.status / agent.message。
- 2025-12-18Agent Run 可靠性增强 —— 并发幂等、终态去重、取消语义加强、Provider 超时/重试/错误归一SSE gap 回补与心跳。
## 本次变更摘要2025-12-18
- RunDispatcher 并发幂等:同 trigger_message_id 只产生一个 RUNNING且仅新建时 dispatch。
- RunLoop/OutputSink 幂等agent.message 与 run.status 采用 dedupe_key重复执行不重复写。
- Cancel 强化:多检查点取消,确保不落 agent.message 且落 CANCELED 终态。
- Provider 可靠性:超时/重试/429/5xx错误落库包含 retryable/http_status/provider/latency_ms。
- SSE 可靠性gap 触发回补心跳保活publish 异常不影响主流程。
## 领域模型
- `ChatSession``session_id`(UUID)、`session_name``status`(`OPEN`/`LOCKED`/`CLOSED`)、`last_seq`
@@ -141,7 +149,8 @@
- `id` 为消息 `seq`,便于续传;`data` 为消息 JSON同追加消息响应字段
- Backlog建立连接后先补发 `seq > after_seq` 的消息order asc最多 `limit` 条),再进入实时订阅。
- 实时Redis channel `session:{session_id}:messages` 发布消息 IDSSE 侧读取后按 seq 去重、推送。
- 心跳:周期输出 `: ping` 保活(生产环境)
- Gap 回补:若订阅推送的 seq 与 last_sent_seq 存在缺口,会主动回补 backlog
- 心跳:周期输出 `: ping` 保活。
- 错误401 未授权404 session 不存在。
## Agent Run MVP-0RunDispatcher + AgentRunJob
@@ -149,13 +158,14 @@
1. 用户追加 `role=USER && type=user.prompt`Controller 自动调用 `RunDispatcher->dispatchForPrompt`
2. 并发保护:同会话只允许一个 RUNNING同一个 `trigger_message_id` 幂等复用已有 `run_id`
3. 立即写入 `run.status`SYSTEM/run.statuspayload `{run_id,status:'RUNNING',trigger_message_id}`dedupe_key=`run:trigger:{message_id}`)。
4. 推送 `AgentRunJob(session_id, run_id)` 到队列(测试环境 QUEUE=sync 会同步执行)。
5. RunLoop使用 DummyAgentProvider
4. 仅在新建 RUNNING 时推送 `AgentRunJob(session_id, run_id)` 到队列(测试环境 QUEUE=sync 会同步执行)。
5. RunLoop默认 HttpAgentProvider未配置 endpoint 时回退 DummyAgentProvider
- 终态检测:若已 DONE/FAILED/CANCELED 则直接返回。
- Cancel 检查:存在 `run.cancel.request`(payload.run_id) 则写入 `run.status=CANCELED`,不产出 agent.message。
- ContextBuilder提取最近 20 条 USER/AGENT 消息type in user.prompt/agent.messageseq 升序提供给 Provider。
- Provider 返回一次性文本回复。
- OutputSink 依次写入:`agent.message`payload 含 run_id, provider`run.status=DONE`dedupe_key=`run:{run_id}:status:DONE`)。
6. 异常:AgentRunJob 捕获异常后写入 `error` + `run.status=FAILED`dedupe
- Provider 返回一次性文本回复(内置超时/重试/退避)
- OutputSink 依次写入:`agent.message`payload 含 run_id, providerdedupe_key=`run:{run_id}:agent:message`)、`run.status=DONE`dedupe_key=`run:{run_id}:status:DONE`)。
6. 异常:ProviderException 写入 `error` + `run.status=FAILED`dedupeerror payload 包含 retryable/http_status/provider/latency_ms
### Run 相关消息类型(落库即真相源)
| type | role | payload 关键字段 | 说明 |
@@ -163,7 +173,7 @@
| run.status | SYSTEM | run_id, status(RUNNING/DONE/CANCELED/FAILED), trigger_message_id?, error? | Run 生命周期事件CLOSED 状态下允许写入 |
| agent.message | AGENT | run_id, provider | Provider 的一次性回复 |
| run.cancel.request | USER/SYSTEM | run_id | CancelChecker 依据该事件判断是否中止 |
| error | SYSTEM | run_id, message | 任务异常时落库 |
| error | SYSTEM | run_id, message, retryable?, http_status?, provider?, latency_ms?, raw_message? | 任务异常时落库 |
### 触发 Run调试入口
- `POST /sessions/{session_id}/runs`

View File

@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: ChatSession & Message API
version: 1.1.0
version: 1.1.1
description: |
ChatSession & Message API含 Archive/GetMessage/SSE 与 Run 调度)。自然语言:中文。
servers:
@@ -15,6 +15,13 @@ tags:
- name: Run
description: Agent Run 调度
paths:
/test:
summary: 测试接口
get:
tags: [Test]
responses:
"200":
description: 成功
/sessions:
post:
tags: [ChatSession]
@@ -422,8 +429,13 @@ components:
type: string
nullable: true
payload:
type: object
nullable: true
oneOf:
- $ref: '#/components/schemas/RunStatusPayload'
- $ref: '#/components/schemas/AgentMessagePayload'
- $ref: '#/components/schemas/RunCancelPayload'
- $ref: '#/components/schemas/RunErrorPayload'
- type: object
reply_to:
type: string
format: uuid
@@ -470,6 +482,59 @@ components:
message:
type: string
example: Session is closed
RunStatusPayload:
type: object
properties:
run_id:
type: string
format: uuid
status:
type: string
enum: [RUNNING, DONE, FAILED, CANCELED]
trigger_message_id:
type: string
format: uuid
nullable: true
error:
type: string
nullable: true
AgentMessagePayload:
type: object
properties:
run_id:
type: string
format: uuid
provider:
type: string
RunCancelPayload:
type: object
properties:
run_id:
type: string
format: uuid
RunErrorPayload:
type: object
properties:
run_id:
type: string
format: uuid
message:
type: string
retryable:
type: boolean
nullable: true
http_status:
type: integer
nullable: true
provider:
type: string
nullable: true
latency_ms:
type: integer
nullable: true
raw_message:
type: string
nullable: true
PaginationLinks:
type: object
properties:

View File

@@ -0,0 +1,53 @@
# Agent Orchestrator 工程级 Review
A. 现状总结
整体链路RunDispatcher → Job → RunLoop → Provider → OutputSink已能跑通职责划分基本清晰append-only + seq 方案也成立但在并发幂等、run 生命周期一致性、取消可靠性、provider 超时/重试,以及 SSE 丢事件补偿方面仍有明显缺口,尚未达到对接真实 LLM Provider 的最低可靠性门槛。
B. 高风险问题列表
- P0 | 影响: 并发触发同一 prompt 时可能生成“幽灵 run”run_id 不一致且重复出消息/计费 | 复现: 并发调用 `dispatchForPrompt` 同一 `trigger_message_id`,第二个请求 dedupe 命中但仍派发新 job | 修复: 以 `appendRunStatus` 返回的 message 为准,若 run_id 不一致则直接返回已有 run_id 且不 dispatch或使用确定性 run_id/事务化 get-or-create | 位置: `app/Services/RunDispatcher.php:21`, `app/Services/OutputSink.php:30`, `app/Services/ChatService.php:61`
- P0 | 影响: job 重试/重复派发会生成多个 `agent.message`,导致重复输出与双重计费 | 复现: 让队列重试或重复 dispatch 同一 run_id | 修复: 给 agent.message 增加 dedupe_key按 run_id + step/chunk并在 RunLoop 开始前检查 run.status 是否已终态 | 位置: `app/Services/OutputSink.php:16`, `app/Services/RunLoop.php:45`, `app/Jobs/AgentRunJob.php:21`
- P1 | 影响: 同 session 可并发多个 RUNNING违反“单 run”假设后续状态/输出互相覆盖 | 复现: 连续/并发发送多条 prompt`latestStatus` 检查无锁 | 修复: 在 session 行锁内判断并创建 RUNNING或用 Redis/DB 锁/独立 run 表做硬约束 | 位置: `app/Services/RunDispatcher.php:40`, `app/Http/Controllers/ChatSessionController.php:45`
- P1 | 影响: run 生命周期非原子写入,崩溃后会出现“有回复但仍 RUNNING”或“RUNNING 无后续” | 复现: 在 `appendAgentMessage` 后杀 worker或 dispatch 失败后 RUNNING 已落库 | 修复: 将 agent.message 与 DONE 写入同一一致性边界;添加 watchdog/finally 标记 FAILED/CANCELED | 位置: `app/Services/RunLoop.php:45`, `app/Services/RunDispatcher.php:53`
- P1 | 影响: cancel 不能严格阻止 agent.message 落库,且取消后可能无 CANCELED 终态 | 复现: cancel 请求在 provider 返回后、写回前到达;或 job 崩溃 | 修复: 在写回前/写回时再校验 cancel在 finally 中若存在 cancel 请求则写 CANCELED | 位置: `app/Services/RunLoop.php:20`, `app/Services/CancelChecker.php:9`
- P1 | 影响: provider 调用可能无限挂起或频繁失败run 长时间 RUNNING | 复现: provider 超时/429/5xx | 修复: 设置 Http timeout/retry/backoff配置 job $timeout/$tries对 429 做退避 | 位置: `app/Services/Agent/HttpAgentProvider.php:20`, `app/Jobs/AgentRunJob.php:13`
- P2 | 影响: SSE 可能漏事件backlog 与订阅之间存在窗口Redis publish 异常会让主流程报错 | 复现: 在 backlog 发送后、订阅前插入消息;或 Redis 异常 | 修复: 订阅后检测 seq gap 回补publish 异常仅告警不抛 | 位置: `app/Http/Controllers/ChatSessionSseController.php:46`, `app/Services/ChatService.php:130`
- P2 | 影响: 上下文固定 20 条且无 token 预算/截断,遇大消息容易爆 token | 复现: 长文本 prompt/agent 输出 | 修复: 通过配置控制窗口/预算并做截断或摘要 | 位置: `app/Services/ContextBuilder.php:10`
- P2 | 影响: JSONB payload 查询无索引run/cancel 查询随数据量增长会退化 | 复现: 大量消息后 run.status/取消查询变慢 | 修复: 加表达式索引 `payload->>'run_id'` / `payload->>'trigger_message_id'` | 位置: `app/Services/RunDispatcher.php:28`, `app/Services/CancelChecker.php:11`
C. 对接真实 LLM Provider 前必须补齐的最小门槛清单
- 并发幂等RunDispatcher 必须做到“同 trigger 只产生一个 run”且不 dispatch 重复 job
- 输出幂等agent.message 必须可去重RunLoop 必须在终态时 no-op
- run 生命周期一致性DONE/FAILED/CANCELED 的落库要有一致性边界或兜底 finalizer
- 取消语义:写回前必须再次校验 cancel并确保取消后不再写 agent.message
- Provider 调用稳定性timeout + retry/backoff + 429/5xx 处理 + job timeout/tries
- SSE 补偿:解决 backlog/subscribe 间隙与 publish 异常导致的漏消息
- Context 预算:可配置窗口/预算并限制大 payload
- 最小测试覆盖:并发幂等、取消、重试/失败路径
D. 建议的下一步路线选择
- 方案1先补 Orchestrator —— 补齐并发幂等、输出去重、生命周期终态一致性、取消写回保护、provider 超时/重试、SSE 缺口补偿、context 预算配置,再接真实 Provider
- 方案2直接接真实 Provider —— 目前仅适合“实验/灰度验证链路通不通”;已有 append-only + seq + SSE 基础与 ContextBuilder 过滤,但不满足可靠性门槛
E. 最小变更的 patch 建议(不要求实现)
- `app/Services/RunDispatcher.php`: `appendRunStatus` 后读取返回 message 的 `payload.run_id`;若与新 runId 不一致则直接返回已有 run_id 且跳过 dispatch必要时把 “单 session 运行中检查” 放入带锁事务
- `app/Services/OutputSink.php`: 允许 `appendAgentMessage` 接收并透传 dedupe_key默认 `run:{runId}:agent:message`),避免重复输出
- `app/Services/RunLoop.php`: 开始时/写回前检查 run 是否已终态;写回 DONE 前再检查 cancel必要时写 CANCELED 并停止
- `app/Jobs/AgentRunJob.php`: 设置 `$tries/$backoff/$timeout`;在 finally 中若仍 RUNNING 则落 FAILED需查询最新 run.status
- `app/Services/Agent/HttpAgentProvider.php` + `config/services.php`: 增加 timeout、retry/backoff 配置项与 429 退避策略
- `app/Services/ChatService.php`: `publishMessageAppended` 改为捕获异常仅记录日志,避免 afterCommit 抛错中断主流程
- `app/Http/Controllers/ChatSessionSseController.php`: 收到 pubsub 时若 seq gap>1 则 `sendBacklog` 回补;可加心跳定期拉取
- 新 migration`messages` 增加表达式索引 `payload->>'run_id'``payload->>'trigger_message_id'`(并结合 `type`)以保证查询性能
## 已实施增强(最小可行版本)
- RunDispatcher 并发幂等RUNNING 消息以 dedupe_key 作为唯一真相,只有新建 RUNNING 才 dispatch
- RunLoop/OutputSink 幂等agent.message/run.status 使用 dedupe_key终态重复执行可安全 no-op
- Cancel/终态收敛:多检查点取消 + Job finally 兜底写入 CANCELED/FAILED
- Provider 可靠性:超时/重试/错误归一化ProviderException并写入错误元数据
- SSE 补偿seq gap 回补 + 心跳 + publish 异常吞吐日志
- Postgres 索引payload 表达式索引加速 run/cancel 查询