通过 WSS 让 Server 安全调用 Desktop 本地工具(上02):拆分会话、执行与状态链 ID

6 阅读1小时+

7. 第二步:执行接口里面发生了什么

所有场景的第二步都是 POST /api/agent,走同一套代码。这一节统一讲一次。

7.1 为什么需要适配器

M3 直接把前端的请求交给官方 LangGraphAgent,官方集成会把请求里的 threadId 写进 LangGraph 的 thread_id。现在 threadId 表示会话、thread_id 表示存档链,两者不再相等,而第三方包的源码不能改。

所以在官方 Agent 外面包了一层 DevMind 自己的适配器 DevMindAgUiAdapter:进去之前,把前端的输入换成数据库里的可信数据(包括把会话 ID 换成存档链 ID);出来之后,把事件里的存档链 ID 换回会话 ID,并把结果写进业务表。驱动 Graph、把 LangGraph 事件翻译成 AG-UI 事件这些最麻烦的事,仍然交给官方 Agent。

7.2 总览:一次执行经过哪些文件

请求进来之前,适配器、官方 Agent、图、模型网关、工具注册表都已经在启动时建好了(见 6.1 节)。请求到达 run_agent() 之前,FastAPI 先做三件事:

  1. 把请求体解析成 RunAgentInput(AG-UI 官方的 Pydantic 模型,前端发的是 threadId 这样的驼峰字段),不合法直接返回 422;
  2. 执行依赖 get_current_user(),得到当前用户;
  3. 执行依赖 get_ag_ui_adapter(),从 app.state 取出适配器。

之后的调用分三段:prepare() 做检查(7.3~7.5 节),stream() 边执行边推送(7.6 节),官方 Agent 在 stream() 里运行图(7.7 节)。

image.png

完整的调用树:

POST /api/agent   body: RunAgentInput{threadId: "c1", runId: "r2", messages: [...]}
│
└─ api/agent.py 的 run_agent()
    │
    ├─ ① adapter.prepare()                         ← 还没返回响应,失败可以直接给 4xx
    │   ├─ context_loader.load_agent_run_context()   按 run_id 从数据库装配可信上下文:
    │   │     执行属于谁、用哪条存档链;
    │   │     normal、retry:最近 20 轮的问答(没完成的带说明)+ 本轮问题
    │   │     resume:数据库里的审批结果
    │   ├─ _validate_protocol_input()                threadId 是否等于会话 ID;normal、retry:最后一条用户消息是否就是本轮问题;
    │   │                                            resume:resume[] 的 interruptId 是否就是这次要处理的中断
    │   └─ service.claim_queued_run()                queued → running;同一个 run_id 第二次执行得到 409
    │
    └─ ② return SseResponse(adapter.stream(...), on_never_started=adapter.abandon)
        │  HTTP 200 响应头发出,开始 SSE
        └─ adapter.stream()
            ├─ clone 官方 Agent,把可信身份写进 config["configurable"]
            ├─ _build_internal_input()               thread_id 换成 graph_thread_id,消息换成数据库里的
            ├─ 生产者任务 _pump_events(agent.run(...)):官方 Agent 运行 LangGraph,事件放进队列
            │   └─ LangGraphAgent.run()
            │       ├─ graph.aget_state()                按 thread_id(存档链)读出存档
            │       ├─ 普通执行:消息转成 LangChain 消息作为图的输入;审批恢复:Command(resume=审批结果)
            │       └─ graph.astream_events()            运行图,把节点产生的事件翻译成 AG-UI 事件
            │           ├─ model 节点 call_model()          → ModelGateway.astream() → ChatOpenAI
            │           ├─ approval 节点 request_approval() → interrupt() 暂停
            │           └─ tools 节点 execute_tools()       → ToolRegistry.execute() → 工具函数
            ├─ 消费者循环:从队列取事件,累积回答文字,把 thread_id 换回会话 ID,发给前端
            └─ 收到 RUN_FINISHED:先读存档判断是完成还是停在审批,写数据库,再把 RUN_FINISHED 发给前端
                  完成     → service.mark_succeeded():写回答,执行 → succeeded,轮 → completed
                  停在审批 → service.mark_interrupted():写中断,执行 → interrupted,轮 → interrupted
            客户端断开或出错:停止生产者,service.mark_stopped():保存半截回答,执行 → cancelled 或 failed

7.3 prepare:执行前的三道检查

调用链路:从请求进来到开始推流。

image.png

顺序文件 · 方法做什么
1api/agent.py · run_agent()先 await adapter.prepare(),通过了才返回 SseResponse。prepare() 里抛出的业务异常由 api/errors.py 转成 4xx
2runs/context_loader.py · load_agent_run_context()借一个连接只读查询:按 run_id 读出执行,校验属于当前用户;普通执行和重跑读历史和本轮问题,审批恢复读审批结果;装成 AgentRunContext
3agent/ag_ui_adapter.py · _validate_protocol_input()请求里的 threadId、最后一条用户消息、resume[] 必须和上下文一致
4runs/service.py · claim_queued_run()一个事务里先锁轮次,再用条件 UPDATE 把执行从 queued 抢成 running,轮次也标为 running;同一个 run_id 第二次来就 409
5api/agent.py · SseResponse包装 adapter.stream() 返回的事件流,用官方 EventEncoder 编码成 SSE;流一次都没开始就断开时调 adapter.abandon()
@router.post("/agent")
async def run_agent(
    input_data: RunAgentInput, request: Request, user: CurrentUserDep, adapter: AgUiAdapterDep
) -> StreamingResponse:
    """AG-UI 正式入口:先校验并抢占 Run(失败直接返回 4xx),成功后才开始 SSE。

    抢占之后、流开始之前客户端就断开的话,stream() 不会执行,由 abandon() 把 Run 标为已停止。
    """
    context = await adapter.prepare(input_data, user_id=user.id)
    return SseResponse(
        adapter.stream(input_data, context), request, on_never_started=lambda: adapter.abandon(context)
    )

为什么拆成 prepare 和 stream 两个方法?流式响应一旦开始,HTTP 状态码就已经是 200,之后再出错只能断流,前端分不清是网络问题还是请求本身有问题。所以加载上下文、校验请求、抢占执行必须在返回 StreamingResponse 之前完成。

这样做有一个代价:抢占之后、推流开始之前有一个很短的窗口。客户端如果恰好在这时断开(断网、关窗口、刷新页面),Starlette 不会开始迭代事件流,stream() 里处理断开的代码一行都不会执行:没开始的异步生成器被关闭时什么也不做。执行和这一轮就会一直停在 running,这个会话既不能重试也不能再提问,只能等 Server 重启。

所以执行接口用的是 SseResponse:它在响应结束时检查事件流有没有开始过,没开始就调用 adapter.abandon(),按“客户端断开”把执行标为 cancelled。收尾放在 CancelScope(shield=True) 里,因为这时往往正处在取消中:

class SseResponse(StreamingResponse):
    """流式响应,外加一个“流从来没开始”时的收尾回调。

    客户端在响应开始前就断开时,Starlette 不会迭代 body,事件流生成器里的收尾代码不会执行。
    __call__ 结束时(不管正常结束还是出错),如果流没有开始过,就调用 on_never_started。
    """

    def __init__(
        self,
        events: AsyncIterator[BaseEvent],
        request: Request,
        on_never_started: Callable[[], Awaitable[None]] | None = None,
    ) -> None:
        encoder = EventEncoder(accept=request.headers.get("accept"))  # 根据 Accept 头创建一次编码器
        self.started = False
        self._on_never_started = on_never_started

        async def event_stream():
            self.started = True  # 进入这里之后,事件流自己负责断开和出错时的收尾
            async for event in events:
                yield encoder.encode(event)

        super().__init__(
            event_stream(),
            media_type=encoder.get_content_type(),
            headers={
                "Cache-Control": "no-store",  # 禁止中间层缓存或重放 Agent 事件
                "X-Accel-Buffering": "no",  # 告诉 Nginx 不要缓冲流式响应
            },
        )

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        try:
            await super().__call__(scope, receive, send)
        finally:
            if not self.started and self._on_never_started is not None:
                with anyio.CancelScope(shield=True):  # 可能正处在取消中,收尾不能被再次取消
                    await self._on_never_started()
async def abandon(self, context: AgentRunContext) -> None:
    """prepare 已经把 Run 抢占为 running,但 stream() 一次都没被迭代,客户端就断开了。

    这时 stream() 里的收尾代码一行都不会执行(没开始的异步生成器被关闭时什么也不做),
    Run 会一直停在 running。由执行接口在响应结束时调用这里,按“客户端断开”收尾。
    """
    await self._service.mark_stopped(
        run_id=context.run_id,
        conversation_id=context.conversation_id,
        turn_id=context.turn_id,
        status=RunStatus.CANCELLED,
        error_code="CLIENT_DISCONNECTED",
    )

测试 test_disconnect_before_stream_starts 直接用 ASGI 调用执行接口,请求体一发完就断开,uvicorn 声明的 ASGI 2.3 和更新的 2.4 两种处理方式都覆盖。去掉 SseResponse 的收尾,这两个测试都会失败,这一轮停在 running。

async def prepare(self, input_data: RunAgentInput, *, user_id: str) -> AgentRunContext:
    """流式响应开始之前完成全部校验;这里抛出的领域异常会被映射成 4xx,而不是 200 + 断流。"""
    context = await load_agent_run_context(
        repository=self._repository, run_id=input_data.run_id, user_id=user_id
    )
    _validate_protocol_input(input_data, context)
    # 条件 UPDATE 抢占:重复请求同一个 run_id 会得到 409
    await self._service.claim_queued_run(run_id=context.run_id)
    return context
def _validate_protocol_input(input_data: RunAgentInput, context: AgentRunContext) -> None:
    """校验 AG-UI 请求与准备接口创建的数据库记录一致。"""
    if input_data.thread_id != context.conversation_id:
        raise RunInputMismatchError("threadId 必须等于 conversation_id")
    # @ag-ui/client 1.0 不发送 parentRunId;只有客户端显式发送时才比对,血缘以数据库为准
    if input_data.parent_run_id is not None and input_data.parent_run_id != context.parent_run_id:
        raise RunInputMismatchError("parentRunId 与数据库记录不一致")

    if context.run_type in (RunType.NORMAL, RunType.RETRY):
        # 新问题和重跑:客户端最后一条用户消息必须就是这一轮当前的问题(编辑后是新问题)
        user_messages = [m for m in input_data.messages if m.role == "user"]
        expected = context.user_message
        if expected is None or not user_messages:
            raise RunInputMismatchError("缺少本轮用户消息")
        last = user_messages[-1]
        if last.id != expected.id or last.content != expected.content:
            raise RunInputMismatchError("用户消息 ID 或内容与准备接口不一致")
    elif context.run_type == RunType.RESUME and input_data.resume:
        # Resume:客户端如果携带 resume[],只能指向本 Run 正在解决的 Interrupt
        if {entry.interrupt_id for entry in input_data.resume} != {context.interrupt_id}:
            raise RunInputMismatchError("resume[].interruptId 与数据库记录不一致")

HttpAgent 会把请求里的消息原样发过来,这里只校验最后一条用户消息必须是这一轮当前的问题(ID 和内容都一致),其他消息一律忽略,历史以数据库为准。另外两项校验:

  • 审批恢复时,如果请求带了 resume[],其中的 interruptId 必须是这次执行要处理的那个中断;审批结果本身不从请求里取。
  • 如果请求带了 parentRunId,必须和数据库里记录的来源执行一致。@ag-ui/client 默认不发这个字段,不发就不校验。

7.4 可信上下文:按 run_id 从数据库装配

浏览器只能提交 threadId 和 runId 来“指认”一次执行,用户身份、存档链、历史消息和审批结果都不从请求里取:

@dataclass(frozen=True, slots=True)
class AgentRunContext:
    """Server 根据数据库重建的可信运行上下文;Adapter、Graph 工具只使用这里的值。"""

    user_id: str
    conversation_id: str
    turn_id: str
    run_id: str
    parent_run_id: str | None
    graph_thread_id: str
    device_id: str | None
    run_type: RunType
    trigger_type: TriggerType
    trace_id: str
    # 普通 Run:本轮用户消息(Renderer 提交的消息必须与它一致);其他 Run 为 None
    user_message: ChatMessage | None
    # 普通 Run / 重跑 Run:最近 20 轮的问答,按时间正序;没完成的轮次带说明;Resume 为空
    history: tuple[ChatMessage, ...]
    # Resume Run:正在解决的 Interrupt 以及准备接口保存的审批结果
    interrupt_id: str | None
    resume_payload: dict[str, Any] | None
async def load_agent_run_context(
    *, repository: RunRepository, run_id: str, user_id: str
) -> AgentRunContext:
    """按 run_id 从数据库重建可信上下文;浏览器提交的身份、Graph Thread 一律不采信。"""
    async with repository.connection() as conn:
        run = await repository.get_run(conn, run_id)
        if run is None:
            raise RunNotFoundError(run_id)
        if run.user_id != user_id:
            # 所有权校验使用认证依赖提供的 user_id,而不是请求体里的任何字段
            raise RunOwnershipError(run_id)

        user_message = None
        history = ()
        resume_payload = None

        if run.run_type == RunType.RESUME:
            # Resume 的对话上下文已经在 Checkpoint 里,只需要带上审批结果
            if run.interrupt_id is None:
                raise RunConflictError(f"resume run {run_id} has no interrupt")
            interrupt = await repository.get_interrupt(conn, run.interrupt_id)
            if interrupt is None or interrupt.resolution_payload is None:
                raise RunConflictError(f"interrupt {run.interrupt_id} is not resolved")
            resume_payload = interrupt.resolution_payload
        else:
            # 普通 Run 和重跑 Run 都在新的 Graph Thread 上执行,需要从业务表装配历史
            history = _history_messages(
                await repository.list_history_turns(
                    conn,
                    conversation_id=run.conversation_id,
                    before_turn_id=run.turn_id,
                    turn_limit=HISTORY_TURN_LIMIT,
                )
            )
            user_message = await repository.get_turn_user_message(conn, run.turn_id)

    return AgentRunContext(
        user_id=run.user_id,
        conversation_id=run.conversation_id,
        turn_id=run.turn_id,
        run_id=run.id,
        parent_run_id=run.parent_run_id,
        graph_thread_id=run.graph_thread_id,
        device_id=run.device_id,
        run_type=run.run_type,
        trigger_type=run.trigger_type,
        trace_id=run.trace_id or run.id,
        user_message=user_message,
        history=history,
        interrupt_id=run.interrupt_id,
        resume_payload=resume_payload,
    )
执行类型装配什么为什么
normal、retry最近 20 轮的问答 + 本轮问题;停止或失败的轮次也带上,并补一句“没有完成”新存档链里什么都没有,上下文要从业务表装配;带上没写完的轮次,用户发“继续”时模型才接得上
resume数据库里的审批结果对话内容已经在原存档链里,只需要告诉 Graph 用户批准了没有

7.5 构造交给官方 Agent 的输入

def _build_internal_input(input_data: RunAgentInput, context: AgentRunContext) -> RunAgentInput:
    """构造交给官方 Agent 的内部输入:只保留客户端的 runId,其余字段全部由 Server 重建。"""
    if context.run_type == RunType.RESUME:
        messages: list[Message] = []  # 对话已在 Checkpoint 中,不能再重复写入
        resume = [
            ResumeEntry(
                interrupt_id=context.interrupt_id,
                status="resolved",
                payload=context.resume_payload,  # 审批结果以数据库为准,不采信客户端 payload
            )
        ]
    else:
        messages = [_to_ag_ui_message(m) for m in context.history]
        messages.append(_to_ag_ui_message(context.user_message))
        resume = None

    return input_data.model_copy(
        update={
            "thread_id": context.graph_thread_id,  # 官方集成会把它写入 configurable.thread_id
            "messages": messages,
            "resume": resume,
            "state": {},             # 不允许客户端直接改写 Graph State
            "tools": [],             # M4 不使用前端工具
            "forwarded_props": {},   # 屏蔽旧版 forwardedProps.command.resume 等私有扩展
        }
    )
字段前端发来的交给官方 Agent 的
thread_id会话 ID存档链 ID,官方集成会把它写进 LangGraph 的 thread_id
messages前端的消息normal、retry:数据库里的历史 + 本轮问题;resume:空,对话已在存档里
resume前端的审批结果用数据库里的审批结果重新生成
state、tools、forwarded_props任意内容清空,前端不能借此改写 Graph 的状态

7.6 stream:边执行边推送

调用链路:stream() 依次调用了谁。

image.png

顺序文件 · 方法做什么
1agent/ag_ui_adapter.py · stream()每个请求 clone() 一份官方 Agent
2agent/graph_config.py · build_run_configurable()把可信身份放进 config["configurable"],图的节点和工具都能读到(下篇的 Desktop 工具要用)
3agent/ag_ui_adapter.py · _build_internal_input()构造交给官方 Agent 的输入,见 7.5 节
4agent/ag_ui_adapter.py · _pump_events()生产者:在独立的 asyncio 任务里迭代 agent.run(),事件和异常都放进队列
5stream() 的消费循环从队列取事件,按类型处理后 yield 出去,由 SseResponse 编码发给前端
6agent/ag_ui_adapter.py · _persist_finished()收到 RUN_FINISHED 时读存档,有中断调 mark_interrupted(),没有调 mark_succeeded(),落库之后才把 RUN_FINISHED 发出去
7agent/ag_ui_adapter.py · _stop_producer()断开或出错时对生产者任务调 task.cancel(),等它停下

下图是消费循环按事件类型怎么处理:事件从生产者经过队列交给消费者,按类型处理后发给前端;消费者还有三条异常出口。

image.png

async def stream(
    self, input_data: RunAgentInput, context: AgentRunContext
) -> AsyncIterator[BaseEvent]:
    """执行 Graph 并转发标准事件;RUN_FINISHED 发给客户端之前先把结果落库。"""
    agent = self._agent.clone()  # 官方 Agent 持有单次运行的可变状态,必须每个请求 clone
    base_config = agent.config or {}
    agent.config = {
        **base_config,
        "configurable": {
            **base_config.get("configurable", {}),
            **build_run_configurable(context),
        },
    }
    internal_input = _build_internal_input(input_data, context)

    # 官方事件流放在独立的 asyncio 任务里运行,通过队列交给本生成器。
    # 客户端断开时 Starlette 用 anyio 取消本协程,这种取消不会可靠地传到 LangGraph 正在执行的节点;
    # 所以断开时由这里对生产者任务调用原生 task.cancel(),确保模型调用和 Desktop 工具立即停止。
    queue: asyncio.Queue[BaseEvent | BaseException | object] = asyncio.Queue(maxsize=64)
    producer = asyncio.create_task(_pump_events(agent.run(internal_input), queue))

    text_buffers: dict[str, list[str]] = {}
    final_text: str | None = None
    finished = False

    def answer_so_far() -> str | None:
        """停止或出错时,已经推给前端的回答:写完的最后一段,加上正在写的那一段。"""
        parts = [final_text or "", *("".join(buffer) for buffer in text_buffers.values())]
        return "".join(parts) or None

    async def mark_stopped(status: RunStatus, error_code: str, error_message: str | None = None) -> None:
        await self._service.mark_stopped(
            run_id=context.run_id,
            conversation_id=context.conversation_id,
            turn_id=context.turn_id,
            status=status,
            error_code=error_code,
            error_message=error_message,
            partial_text=answer_so_far(),  # 保存半截回答,用户接着发“继续”时模型能看到
        )

    try:
        while (item := await queue.get()) is not _STREAM_END:
            if isinstance(item, BaseException):
                raise item
            event: BaseEvent = item
            if event.type == EventType.TEXT_MESSAGE_START:
                text_buffers[event.message_id] = []
            elif event.type == EventType.TEXT_MESSAGE_CONTENT:
                text_buffers.setdefault(event.message_id, []).append(event.delta)
            elif event.type == EventType.TEXT_MESSAGE_END:
                # 一个 Run 的最后一段 assistant 文本视为最终回答
                final_text = "".join(text_buffers.pop(event.message_id, []))
            elif event.type == EventType.TOOL_CALL_START:
                final_text = None  # 工具调用之前的文本只是过程说明,不是最终回答,停止时也不保存

            if event.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
                # 内部用 graph_thread_id 执行,返回给客户端时恢复成 conversation_id
                event = event.model_copy(update={"thread_id": context.conversation_id})

            if event.type == EventType.RUN_FINISHED:
                # 模型流被中途截断(例如模型超时)时,已经开始的文字消息收不到 TEXT_MESSAGE_END。
                # 直接发 RUN_FINISHED 违反协议,@ag-ui/client 会拒绝整次执行,所以先替它补上结束事件
                for message_id in list(text_buffers):
                    yield TextMessageEndEvent(message_id=message_id)
                if await self._stopped_by_model_timeout(context):
                    # 模型超时:已经写出的部分存成半截回答,这一轮记为失败,可以重试或发“继续”
                    await mark_stopped(RunStatus.FAILED, "MODEL_TIMEOUT", "模型长时间没有输出,已停止")
                    await _stop_producer(producer)
                    finished = True
                    yield RunErrorEvent(
                        message="模型长时间没有输出,已停止;已经写出的部分已保存,可以重试或发送“继续”",
                        code="MODEL_TIMEOUT",
                    )
                    break
                await self._persist_finished(context, answer_so_far() if text_buffers else final_text)
                finished = True
            elif event.type == EventType.RUN_ERROR:
                await mark_stopped(RunStatus.FAILED, event.code or "AGENT_RUN_ERROR", event.message)
                finished = True
            yield event
    except (asyncio.CancelledError, GeneratorExit):
        # 客户端断开或点击停止。anyio 的取消会反复生效,收尾操作必须放在屏蔽取消的作用域里
        with anyio.CancelScope(shield=True):
            await _stop_producer(producer)  # 先停掉 Graph,正在等待的 Desktop 工具会收到取消
            await mark_stopped(RunStatus.CANCELLED, "CLIENT_DISCONNECTED")
        raise
    except Exception as exc:
        logger.exception("agent run failed run_id=%s", context.run_id)
        await _stop_producer(producer)
        await mark_stopped(RunStatus.FAILED, "AGENT_RUN_FAILED", str(exc)[:500])
        yield RunErrorEvent(message="Agent 运行失败", code="AGENT_RUN_FAILED")
        return

    if not finished:
        # 事件流意外结束且没有终态事件:按失败处理,避免 Run 永远停在 running
        await mark_stopped(RunStatus.FAILED, "STREAM_ENDED_WITHOUT_TERMINAL")

几个关键点:

  • 每个请求 clone() 一份官方 Agent:它持有单次运行的可变状态,不能在请求之间共用。
  • 工具调用之前的文字不算回答:遇到工具调用就清空已累积的文字,最后一段文字才是最终回答。
  • 先落库,再发 RUN_FINISHED:前端一收到结束事件就会去读轮次,这时回答或中断一定已经在数据库里了。
  • 发 RUN_FINISHED 之前补齐没结束的文字消息:模型流被中途截断时(例如模型超时),已经开始的文字消息收不到 TEXT_MESSAGE_END。协议规定还有文字消息没结束时不能发 RUN_FINISHED,@ag-ui/client 会拒绝整次执行,所以适配器先替它补上结束事件,见 7.9 节。
  • 停止或出错时保存半截回答:answer_so_far() 取已经推给前端的文字(写完的最后一段加上正在写的那段),交给 mark_stopped 存下来,见场景 5。
  • 为什么官方事件流要放进单独的任务:点停止时 Starlette 用 anyio 取消响应生成器,实测这种取消传不到 LangGraph 正在执行的节点,数据库里已经是 cancelled,后台的模型调用却还在跑。放进单独的 asyncio 任务后,断开时对它调用原生的 task.cancel(),LangGraph 会立即停下。anyio 的取消会持续生效,所以收尾操作放在 CancelScope(shield=True) 里才能完成。

7.7 图的内部:官方 Agent 怎么运行 LangGraph

stream() 把输入交给 LangGraphAgent.run() 之后,就进了官方集成和 M3 写的图。这一段本篇只改了模型超时,但要看懂一次执行,得知道它怎么往下走。

调用链路:从官方 Agent 一路到模型和工具。

image.png

文件 · 方法做什么
ag_ui_langgraph · LangGraphAgent.run()官方集成。用 thread_id 读出存档;普通执行把消息作为输入,审批恢复把 resume 转成 Command(resume=...);然后运行图,把事件翻译成 AG-UI 事件
graph.py · call_model()模型节点。每次都在最前面加上系统提示词;用空闲 60 秒、总共 600 秒两道超时消费 ModelGateway.astream();把一段段输出合成一条完整的 AIMessage 写进状态
model_gateway.py · ModelGateway.astream()调已经绑定了工具的 ChatOpenAI,逐段返回
graph.py · route_after_model()条件边:结束、审批、执行工具、拒绝并行调用、工具次数到上限,五选一
graph.py · request_approval()审批节点。interrupt() 暂停并存档;恢复时 interrupt() 返回审批结果,只有明确的 approved: true 才去执行工具
graph.py · route_after_approval()批准了去 tools,否则回 model
graph.py · execute_tools()工具节点。调 ToolRegistry.execute(),把结果作为 ToolMessage 写进状态,再回到模型
tool_registry.py · ToolRegistry.execute()按名字找工具,10 秒超时执行,任何错误都转成 ok: false 的工具结果

每个节点执行完,LangGraph 都会通过存档器往 PostgreSQL 写一次存档。所以停在审批时,存档里记着“停在 approval 节点”,恢复时直接从那里接着走,不会再问一遍模型;而停止时正在执行的那个节点没有存档,这也是 6.8 节里不做“从断点接着执行”的原因。

7.8 落库:完成还是停在审批

async def _persist_finished(self, context: AgentRunContext, final_text: str | None) -> None:
    """RUN_FINISHED 时读取 Checkpoint:有待处理的 Interrupt 就记为 interrupted,否则 succeeded。"""
    snapshot = await self._graph.aget_state(build_graph_config(context.graph_thread_id))
    checkpoint_id = snapshot.config["configurable"].get("checkpoint_id")
    if snapshot.interrupts:
        await self._service.mark_interrupted(
            run_id=context.run_id,
            turn_id=context.turn_id,
            graph_thread_id=context.graph_thread_id,
            checkpoint_id=checkpoint_id,
            interrupts=[_to_interrupt_info(item.id, item.value) for item in snapshot.interrupts],
        )
    else:
        await self._service.mark_succeeded(
            run_id=context.run_id,
            conversation_id=context.conversation_id,
            turn_id=context.turn_id,
            final_text=final_text,
            checkpoint_id=checkpoint_id,
        )

中断 ID 取自存档里的 snapshot.interrupts[].id,和官方集成在 RUN_FINISHED 里发给前端的中断 ID 相同,前端和数据库对得上。

LangGraph 的图(agent/langgraph_runtime/graph.py)是 M3 写的,本篇只改了模型超时(见 7.9 节)。和审批有关的是审批节点:它调用 interrupt() 暂停,恢复时读取返回值里的 approved 字段,所以 Service 保存审批结果时固定写成 {"approved": true/false}:

decision = interrupt({  # 保存 Checkpoint 并暂停,恢复时返回用户提交的 payload
    "reason": "tool_approval",  # 提供稳定的中断原因
    "message": f"是否允许执行工具 {tool_call['name']}?",  # 提供 UI 展示文案
    "tool_call_id": str(tool_call["id"]),  # 关联原始工具调用
    "metadata": {"tool_name": str(tool_call["name"]), "arguments": tool_call.get("args", {})},  # 保存展示信息
})  # interrupt 之前不能执行任何外部副作用

cancelled = isinstance(decision, dict) and decision.get("__agui_cancelled__") is True  # 识别 AG-UI 取消哨兵
approved = isinstance(decision, dict) and decision.get("approved") is True  # 只接受明确的布尔批准

7.9 模型超时:保存已经写出的部分

M3 的图给每次模型调用设了“整个流最多 60 秒”的上限。回答写得久一点(比如列一百多行),60 秒一到就在中途被截断,问题接连出现:

  1. 已经开始的文字消息被截断,收不到 TEXT_MESSAGE_END,图却照常结束,官方集成随即发出 RUN_FINISHED;
  2. 协议规定还有文字消息没结束时不能发 RUN_FINISHED,@ag-ui/client 直接拒绝整次执行,页面报错 Cannot send 'RUN_FINISHED' while text messages are still active;
  3. 适配器只在收到 TEXT_MESSAGE_END 时才记下回答,于是这次执行被记为成功,却一个字的回答都没保存,已经写出的内容全丢了。

修复分两处。第一处在图里:超时不再限制总时长,而是“多久没有收到新内容”(包括思考过程)。回答写得再长,只要一直在输出就不会被打断;真的卡住 60 秒才停止。另外保留一个 10 分钟的总时长兜底:

stream = gateway.astream(model_messages)  # 显式逐段消费真实模型流
try:  # 把模型超时转换成稳定状态,而不是无限等待
    async with asyncio.timeout(MODEL_TOTAL_TIMEOUT_SECONDS):  # 总时长兜底
        while True:
            async with asyncio.timeout(MODEL_IDLE_TIMEOUT_SECONDS):  # 每等一个新 Chunk 单独计时
                try:
                    chunk = await anext(stream)
                except StopAsyncIteration:
                    break
            full_chunk = chunk if full_chunk is None else full_chunk + chunk  # 聚合文本和 Tool Call Chunk
except TimeoutError:  # 捕获模型超时:已经流式推给前端的部分由 AG-UI 适配器保存为半截回答
    await stream.aclose()  # 关闭模型流,释放底层连接
    return {  # 返回稳定超时消息
        "messages": [AIMessage(content="模型调用超时,本次运行已停止。")],  # 给 UI 可展示的说明
        "stop_reason": "model_timeout",  # 保存稳定停止原因
        "current_step": "model_timeout",  # 记录当前步骤
    }

第二处在适配器里:收到 RUN_FINISHED 时,先给还没结束的文字消息补发 TEXT_MESSAGE_END;再看图的状态,如果是模型超时(stop_reason = model_timeout),就把已经写出的部分存成半截回答,这一轮记为失败(MODEL_TIMEOUT),并改发 RUN_ERROR。用户看到的效果和停止一样:半截回答保留,可以点重试,或者直接发“继续”:

async def _stopped_by_model_timeout(self, context: AgentRunContext) -> bool:
    """Graph 的模型节点超时后会在状态里写 stop_reason = model_timeout,然后正常结束。"""
    snapshot = await self._graph.aget_state(build_graph_config(context.graph_thread_id))
    return snapshot.values.get("stop_reason") == "model_timeout"

测试 test_model_timeout_keeps_partial_answer 用一个写出前半段后卡住的假模型,并把空闲时限调到 0.3 秒:事件序列里文字消息先结束、最后是 RUN_ERROR;这一轮记为失败并保存了前半段;接着发“继续”,模型能看到这半截回答。换回修复前的适配器,这个测试失败。

8. 方法细节

前面几节已经把“谁调用谁”讲清楚了。这一节逐个看关键方法的内部实现,建议改代码时再对照着读。

8.1 Service 的事务入口:_transaction

文件:runs/service.py

所有写操作都通过它进入:借连接、开事务,并把数据库的唯一约束冲突转换成业务异常。

@asynccontextmanager
async def _transaction(self) -> AsyncGenerator[AsyncConnection, None]:
    """所有写操作共用的事务入口:借连接、开事务,并把唯一约束冲突转换为领域异常。

    @asynccontextmanager 的用法:yield 之前的代码在进入 async with 时执行,
    yield 出去的 conn 就是 as 后面拿到的变量,async with 块结束后再回到 yield 之后。
    `async with A() as conn, conn.transaction():` 等价于两层嵌套的 async with:
    先借连接,再在这个连接上开事务;退出时先提交或回滚事务,再归还连接。

    加锁顺序:所有事务第一把锁都是幂等键(只有准备方法有),之后一律按
    “会话 → 轮次 → 执行 → 中断”的顺序加锁,用不到的层级可以跳过,但不能倒过来。
    两个事务按同样的顺序抢锁,就不会出现“A 等 B、B 等 A”的死锁。

    正常流程下,锁和前置检查已经排除了重复写入;唯一约束是最后一道防线。
    一旦触发(例如以后改代码漏了加锁),返回 409 RUN_CONFLICT,而不是 500。
    """
    try:
        async with self._repo.connection() as conn, conn.transaction():
            yield conn
    except UniqueViolation as exc:
        # 事务已经回滚;约束名写进消息,方便从日志定位是哪条规则被违反
        raise RunConflictError(f"unique constraint violated: {exc.diag.constraint_name}") from exc

用法是 async with self._transaction() as conn:,块里调用的所有 Repository 方法都在同一个事务里,块正常结束就提交,块内抛出任何异常就整体回滚。

except UniqueViolation 是兜底:正常流程下,锁和前置检查已经排除了重复写入,唯一约束不会被触发。一旦触发(比如以后改代码漏了加锁),前端拿到的是 409 和被违反的约束名,而不是 500。

8.2 提新问题:prepare_new_turn

场景 1 和场景 2 都走这个方法。流程图中的圆角框是出口:走到出口就返回或抛出异常,后面的步骤不再执行。

image.png

async def prepare_new_turn(
    self,
    *,
    user_id: str,
    conversation_id: str | None,
    content: str,
    idempotency_key: str,
    device_id: str | None,
) -> PreparedRun:
    """场景:提一个新问题。同一事务内创建 Turn、用户消息和 queued Run;幂等键重复时返回已有结果。

    conversation_id 为 None 表示新会话的第一问:会话也在这个事务里创建,ID 由 Server 生成,
    通过返回值的 conversation_id 交给前端,之后的提问都带上它。
    已有会话时先看最新一轮:还在执行或等待审批就不能提新问题;停止、失败后可以,
    例如用户停止后发一句“继续”,新一轮的上下文里会带上上一轮没写完的回答。
    """
    async with self._transaction() as conn:
        # 1. 锁幂等键:同一个键的并发提交在这里排队。
        #    新会话还没有会话行可锁,所以不能靠锁会话行来排队
        await self._repo.lock_idempotency_key(
            conn, user_id=user_id, idempotency_key=idempotency_key
        )

        # 2. 幂等:锁内检查,重复提交直接返回第一次创建的 Run。
        #    第一问的响应丢了、前端不带 conversation_id 重发,也能在这里找回第一次建好的会话
        existing = await self._repo.find_run_by_idempotency(
            conn, user_id=user_id, idempotency_key=idempotency_key
        )
        if existing is not None:
            self._ensure_same_request(
                existing, run_type=RunType.NORMAL, conversation_id=conversation_id
            )
            return await self._to_prepared(conn, existing)

        # 3. 会话:没带 ID 就新建,标题取问题的前 50 个字;带了 ID 就锁住并校验所有权
        if conversation_id is None:
            conversation_id = new_id()
            await self._repo.create_conversation(
                conn,
                conversation_id=conversation_id,
                user_id=user_id,
                title=content.strip()[:_TITLE_LENGTH],
            )
        else:
            conversation = await self._repo.lock_conversation(conn, conversation_id)
            if conversation is None:
                raise ConversationNotFoundError(conversation_id)
            if conversation.user_id != user_id:
                raise RunOwnershipError(conversation_id)
            await self._check_latest_turn_finished(conn, conversation_id)

        # 4. 业务 ID 全部由 Server 生成;Graph Thread 为新问题新建
        turn_id, message_id, run_id = new_id(), new_id(), new_id()
        sequence_no = await self._repo.next_turn_sequence(conn, conversation_id)
        await self._repo.insert_turn(
            conn, turn_id=turn_id, conversation_id=conversation_id, sequence_no=sequence_no
        )
        await self._repo.insert_message(
            conn,
            message_id=message_id,
            conversation_id=conversation_id,
            turn_id=turn_id,
            run_id=run_id,
            role="user",
            content=content,
        )
        run = await self._repo.insert_run(
            conn,
            run_id=run_id,
            user_id=user_id,
            conversation_id=conversation_id,
            turn_id=turn_id,
            parent_run_id=None,
            graph_thread_id=new_id(),
            device_id=device_id,
            source_checkpoint_id=None,
            interrupt_id=None,
            idempotency_key=idempotency_key,
            run_type=RunType.NORMAL,
            trigger_type=TriggerType.USER_INPUT,
            turn_run_sequence=1,
        )
        # 5. 回填 Turn 的问题和当前生效 Run
        await self._repo.set_turn_entry(
            conn, turn_id=turn_id, user_message_id=message_id, active_run_id=run_id
        )
        return self._build_prepared(run, user_message_id=message_id)

轮次和执行互相引用:轮次要记当前执行 active_run_id,执行要记所属轮次 turn_id,没法一条 INSERT 同时写全,所以先插轮次,最后用 set_turn_entry 回填。

8.3 幂等是怎么做到的

为什么按“用户 + 幂等键”判重。新会话的第一问还没有会话 ID,如果它的响应丢了,前端重发时同样不带会话 ID,按“会话 + 幂等键”就查不到第一次的结果。所以唯一索引是 (user_id, idempotency_key),查找也按用户查:

async def find_run_by_idempotency(
    self, conn: AsyncConnection, *, user_id: str, idempotency_key: str
) -> AgentRunRecord | None:
    """按“用户 + 幂等键”查找已创建的 Run;命中说明这是一次重复提交。"""
    cur = await conn.execute(
        "SELECT * FROM agent_run WHERE user_id = %s AND idempotency_key = %s",
        (user_id, idempotency_key),
    )
    row = await cur.fetchone()
    return AgentRunRecord.from_row(row) if row else None

为什么要先锁幂等键,再查。假设用户双击发送,两个请求带着同一个幂等键同时到达:

image.png

如果先查再加锁,两个请求可能同时查到“没有”,然后各建一个。先加锁,第二个请求只能等第一个提交之后再查,一定能看到第一个请求的结果。新会话还没有会话行可以锁,所以锁的是幂等键本身,用的是 PostgreSQL 的 advisory lock:

async def lock_idempotency_key(
    self, conn: AsyncConnection, *, user_id: str, idempotency_key: str
) -> None:
    """锁住“用户 + 幂等键”这个组合,同一个键的并发请求在这里排队。

    新会话的第一问还没有会话行可以 FOR UPDATE,所以改锁幂等键本身。
    pg_advisory_xact_lock 是 PostgreSQL 的事务级“建议锁”:锁的不是某一行,而是一个整数,
    同一个整数同一时刻只有一个事务能拿到,事务提交或回滚时自动释放。
    hashtextextended 把字符串哈希成 64 位整数;不同的键哈希碰撞的概率极低,
    就算碰上也只是让两个无关请求多排一次队,不影响正确性。
    """
    await conn.execute(
        "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
        (f"{user_id}:{idempotency_key}",),
    )

这把锁确实起作用:测试里用同一个幂等键并发发 5 个首问请求,只建出一个会话。临时去掉这把锁再跑,5 个请求里有 2 个返回 409,靠唯一约束兜底才没有写出脏数据。

同一个键被用在别的请求上。幂等键按用户判重,如果前端有 bug,把上一次的键拿去做别的操作,就会查到一个不相干的执行。所以命中时还要核对是不是同一个请求:

@staticmethod
def _ensure_same_request(
    existing: AgentRunRecord,
    *,
    run_type: RunType,
    conversation_id: str | None = None,
    interrupt_id: str | None = None,
    turn_id: str | None = None,
) -> None:
    """幂等键命中时,确认这是同一个请求的重发,而不是前端把旧的键用在了另一个请求上。

    幂等键按“用户 + 键”判重,所以要额外核对请求的目标:类型相同,
    新问题的会话相同、审批的 Interrupt 相同、重跑的 Turn 相同。不一致返回 409。
    """
    if (
        existing.run_type != run_type
        or (conversation_id is not None and existing.conversation_id != conversation_id)
        or (interrupt_id is not None and existing.interrupt_id != interrupt_id)
        or (turn_id is not None and existing.turn_id != turn_id)
    ):
        raise IdempotencyKeyReusedError(f"idempotency key already used by run {existing.id}")

加锁顺序。三个准备方法的第一把锁都是幂等键;之后所有事务,包括执行期的 claim_queued_run 和 mark_*,一律按“会话 → 轮次 → 执行 → 中断”的顺序加锁,用不到的层级可以跳过,但不能倒过来。两个事务按同样的顺序抢锁,就不会出现“A 等 B、B 等 A”的死锁。审批只知道中断 ID,所以先不加锁地查出中断、执行和轮次,再按顺序加锁,加锁后重新检查状态。唯一的例外是启动时的 recover_after_restart:它用一条 SQL 先改执行、再改轮次,但只在 Server 启动、还没接收请求时运行,不会和别的事务并发。

行锁用 FOR NO KEY UPDATE ,不用 FOR UPDATE。只统一顺序还不够。PostgreSQL 做外键检查时,会给被引用的父行加一把 KEY SHARE 锁,保证父行在事务结束前不会被删掉,这把锁不在我们的代码里,却也参与了排队。统一顺序之后,测试仍然死锁,把死锁等待时间调长、在卡住时查 pg_locks,看到的是:

  • “编辑”用 FOR UPDATE 锁着会话行,在等轮次行;
  • “审批”锁着轮次行,执行到更新轮次状态的 UPDATE conversation_turn 时,PostgreSQL 对轮次的外键 conversation_id 做了检查,要给会话行加 KEY SHARE,而 FOR UPDATE 和 KEY SHARE 冲突,于是等“编辑”。

两边互相等待,死锁。FOR NO KEY UPDATE 的意思是“我会改这一行,但不改主键”,它和同类锁照样互斥,但和 KEY SHARE 不冲突。我们从不修改主键,所以全部改用它:

async def lock_turn(self, conn: AsyncConnection, turn_id: str) -> dict[str, Any] | None:
    """锁住 Turn 行:分配 Run 序号、Message 序号、切换 active_run_id 前必须先锁。

    Turn 只在 Service 内部使用几个字段,这里直接返回 dict,没有单独定义 dataclass。
    """
    cur = await conn.execute(
        "SELECT * FROM conversation_turn WHERE id = %s FOR NO KEY UPDATE",
        (turn_id,),
    )
    return await cur.fetchone()

测试 test_approval_and_edit_at_the_same_time 连续 5 次同时发出审批和编辑,要求编辑总是成功、审批要么成功要么返回 409。去掉上面两处中的任意一处(改回旧的加锁顺序,或者改回 FOR UPDATE),这个测试都会因为 DeadlockDetected 失败。

8.4 审批:prepare_resume

场景 3 走这个方法:

image.png

async def prepare_resume(
    self,
    *,
    user_id: str,
    interrupt_id: str,
    approved: bool,
    idempotency_key: str,
) -> PreparedRun:
    """场景:点审批(允许或拒绝)。在同一轮里新建 Resume Run,复用原 Graph Thread,并消费 Interrupt。

    前端只需要提交 interrupt_id:它属于哪个 Run、哪一轮,都由数据库反查。
    """
    async with self._transaction() as conn:
        # 1. 锁幂等键,2. 幂等:重复点击同一个审批按钮,返回第一次创建的 Resume Run
        await self._repo.lock_idempotency_key(
            conn, user_id=user_id, idempotency_key=idempotency_key
        )
        existing = await self._repo.find_run_by_idempotency(
            conn, user_id=user_id, idempotency_key=idempotency_key
        )
        if existing is not None:
            self._ensure_same_request(existing, run_type=RunType.RESUME, interrupt_id=interrupt_id)
            # 同一张卡片先点了允许、又用同一个键点拒绝:不能悄悄返回“允许”的那次执行
            resolved = await self._repo.get_interrupt(conn, interrupt_id)
            if (resolved.resolution_payload or {}).get("approved") != approved:
                raise InterruptAlreadyResolvedError(interrupt_id)
            return await self._to_prepared(conn, existing)

        # 3. 由 Interrupt 反查被中断的 Run(来源 Run)和它所在的轮次。先不加锁地读,
        #    然后按“轮次 → 执行 → 中断”的统一顺序加锁(见 _transaction 的说明)
        found = await self._repo.get_interrupt(conn, interrupt_id)
        if found is None:
            raise InterruptNotFoundError(interrupt_id)
        source = await self._repo.get_run(conn, found.run_id)
        if source.user_id != user_id:
            raise RunOwnershipError(source.id)
        await self._repo.lock_turn(conn, source.turn_id)
        parent = await self._repo.lock_run(conn, source.id)

        # 4. 锁 Interrupt 后再检查:仍是 open,且来源 Run 停在 interrupted。
        #    同一张卡片的并发审批、审批和编辑同时发生,都在前面的锁上排队,后到的在这里得到 409
        interrupt = await self._repo.lock_interrupt(conn, interrupt_id)
        if interrupt.status != "open":
            raise InterruptAlreadyResolvedError(interrupt_id)
        if parent.status != RunStatus.INTERRUPTED:
            raise InvalidResumeTargetError(parent.id)

        # 5. 复用原 Turn(上面已经锁住),分配 Run 序号
        sequence = await self._repo.next_turn_run_sequence(conn, parent.turn_id)
        run = await self._repo.insert_run(
            conn,
            run_id=new_id(),
            user_id=user_id,
            conversation_id=parent.conversation_id,
            turn_id=parent.turn_id,
            parent_run_id=parent.id,
            graph_thread_id=parent.graph_thread_id,  # 复用状态链,才能从 Checkpoint 继续
            device_id=parent.device_id,
            source_checkpoint_id=interrupt.checkpoint_id,
            interrupt_id=interrupt.id,
            idempotency_key=idempotency_key,
            run_type=RunType.RESUME,
            trigger_type=TriggerType.APPROVAL,
            turn_run_sequence=sequence,
        )
        # 6. 消费 Interrupt:resolution_payload 的结构必须与 Graph 中 interrupt() 期望的返回值一致
        await self._repo.resolve_interrupt(
            conn,
            interrupt_id=interrupt.id,
            resolution_payload={"approved": approved},
            resolved_by_run_id=run.id,
            resolved_by_user_id=user_id,
        )
        # 7. Turn 的当前生效 Run 切到新的 Resume Run,状态回到 pending(审批卡片消失,等待第二步)
        await self._repo.set_turn_active_run(conn, turn_id=parent.turn_id, run_id=run.id)
        await self._repo.update_turn_status_for_run(
            conn, turn_id=parent.turn_id, run_id=run.id, status=TurnStatus.PENDING
        )
        return self._build_prepared(run, user_message_id=None)

前端只需要提交 interrupt_id:它属于哪次执行、哪一轮,都由数据库反查,前端传不了错的来源执行。

8.5 编辑或重试:prepare_rerun

场景 4 走这个方法,编辑和原样重试的区别只在“问题改了吗”这一步:

image.png

async def prepare_rerun(
    self,
    *,
    user_id: str,
    turn_id: str,
    content: str | None,
    idempotency_key: str,
) -> PreparedRun:
    """场景:编辑或重试最新一轮。复用原 Turn,新建 Run 和 Graph Thread,从这一轮的问题重新开始。

    content 为 None(或与原问题相同)是原样重试;否则是编辑:旧问题和旧回答标为 replaced,写入新问题。
    不管哪种,这一轮在页面上始终只有一问一答,后台多了一个 Run。
    """
    async with self._transaction() as conn:
        # 1. 锁幂等键,2. 幂等
        await self._repo.lock_idempotency_key(
            conn, user_id=user_id, idempotency_key=idempotency_key
        )
        existing = await self._repo.find_run_by_idempotency(
            conn, user_id=user_id, idempotency_key=idempotency_key
        )
        if existing is not None:
            self._ensure_same_request(existing, run_type=RunType.RETRY, turn_id=turn_id)
            return await self._to_prepared(conn, existing)

        # 3. 锁会话,再锁 Turn:“这一轮是不是最新一轮”要在锁内判断,
        #    否则可能同时有人在这个会话里提了新问题
        found = await self._repo.get_turn(conn, turn_id)
        if found is None:
            raise TurnNotFoundError(turn_id)
        conversation = await self._repo.lock_conversation(conn, found["conversation_id"])
        if conversation is None:
            raise TurnNotFoundError(turn_id)
        if conversation.user_id != user_id:
            raise RunOwnershipError(turn_id)
        turn = await self._repo.lock_turn(conn, turn_id)
        if turn["sequence_no"] != conversation.last_turn_seq:
            raise TurnNotLatestError(turn_id)

        # 4. 处理这一轮当前生效的 Run:执行中要先停止;领了凭证没执行的作废;等待审批的审批作废
        active = await self._repo.lock_run(conn, turn["active_run_id"])
        if active.status == RunStatus.RUNNING:
            raise RunInProgressError(active.id)
        if active.status == RunStatus.QUEUED:
            await self._repo.cancel_queued_run(conn, run_id=active.id, error_code="REPLACED_BY_RERUN")
        if active.status == RunStatus.INTERRUPTED:
            await self._repo.cancel_open_interrupts(conn, active.id)

        # 5. 旧回答标为 replaced;编辑时连旧问题一起替换,写入新问题
        question = await self._repo.get_turn_user_message(conn, turn_id)
        edited = content is not None and content != question.content
        await self._repo.replace_turn_messages(conn, turn_id=turn_id, include_question=edited)
        run_id = new_id()
        user_message_id = question.id
        if edited:
            user_message_id = new_id()
            await self._repo.insert_message(
                conn,
                message_id=user_message_id,
                conversation_id=conversation.id,
                turn_id=turn_id,
                run_id=run_id,
                role="user",
                content=content,
            )
            await self._repo.set_turn_user_message(
                conn, turn_id=turn_id, user_message_id=user_message_id
            )
            if turn["sequence_no"] == 1:
                # 会话标题取自第一轮问题,编辑第一轮时一起更新
                await self._repo.update_conversation_title(
                    conn, conversation_id=conversation.id, title=content.strip()[:_TITLE_LENGTH]
                )

        # 6. 新建 Run:新的 Graph Thread,上下文和普通提问一样从业务表重新装配
        sequence = await self._repo.next_turn_run_sequence(conn, turn_id)
        run = await self._repo.insert_run(
            conn,
            run_id=run_id,
            user_id=user_id,
            conversation_id=conversation.id,
            turn_id=turn_id,
            parent_run_id=active.id,
            graph_thread_id=new_id(),  # 完整重跑:新状态链,不继承旧 Run 的 State
            device_id=active.device_id,
            source_checkpoint_id=None,
            interrupt_id=None,
            idempotency_key=idempotency_key,
            run_type=RunType.RETRY,
            trigger_type=TriggerType.EDIT if edited else TriggerType.RETRY,
            turn_run_sequence=sequence,
        )
        # 7. Turn 的当前生效 Run 切到新 Run,状态回到 pending
        await self._repo.set_turn_active_run(conn, turn_id=turn_id, run_id=run.id)
        await self._repo.update_turn_status_for_run(
            conn, turn_id=turn_id, run_id=run.id, status=TurnStatus.PENDING
        )
        return self._build_prepared(run, user_message_id=user_message_id)

几个设计点:

  • “最新一轮”要在锁内判断。先锁会话再比较这一轮的序号和会话的 last_turn_seq,否则可能刚判断完,就有人在这个会话里提了新问题。
  • 重跑用新的存档链。编辑后问题变了,旧存档里的内容已经没用;原样重试时,旧存档可能停在失败或取消的半路。新开一条存档链,上下文按普通提问的方式从业务表重新装配,最干净。
  • 编辑第 1 轮时改会话标题,因为标题取自第一个问题。

8.6 执行的状态流转

准备方法只创建 queued 的执行,真正执行在第二步。一次执行从创建到结束的状态变化如下,每个箭头对应 Service 的一个方法:

image.png

async def claim_queued_run(self, *, run_id: str) -> AgentRunRecord:
    """queued → running 的原子抢占;同一个 Run 只有一个 AG-UI 请求能进入 Graph。"""
    async with self._transaction() as conn:
        found = await self._repo.get_run(conn, run_id)
        if found is None:
            raise RunConflictError(f"run {run_id} is not queued")
        await self._repo.lock_turn(conn, found.turn_id)  # 统一的加锁顺序:先轮次,再执行
        run = await self._repo.claim_queued_run(conn, run_id)
        if run is None:
            raise RunConflictError(f"run {run_id} is not queued")
        await self._repo.update_turn_status_for_run(
            conn, turn_id=run.turn_id, run_id=run.id, status=TurnStatus.RUNNING
        )
        return run
async def mark_succeeded(
    self,
    *,
    run_id: str,
    conversation_id: str,
    turn_id: str,
    final_text: str | None,
    checkpoint_id: str | None,
) -> None:
    """Run 正常结束:写入 AI 最终回答,Run → succeeded,Turn → completed。"""
    async with self._transaction() as conn:
        await self._repo.lock_turn(conn, turn_id)
        if not await self._repo.finish_run(
            conn, run_id=run_id, status=RunStatus.SUCCEEDED, last_checkpoint_id=checkpoint_id
        ):
            return  # Run 已被其他路径结束(例如取消),不再写消息
        if final_text:
            await self._repo.insert_message(
                conn,
                message_id=new_id(),
                conversation_id=conversation_id,
                turn_id=turn_id,
                run_id=run_id,
                role="assistant",
                content=final_text,
            )
        await self._repo.update_turn_status_for_run(
            conn, turn_id=turn_id, run_id=run_id, status=TurnStatus.COMPLETED
        )

mark_* 方法都依赖 Repository 的 finish_run 只修改仍在 running 的执行:如果执行已经被别的路径结束了(例如先被取消),finish_run 返回 False,这里直接返回,不再写回答。mark_interrupted 的写法相同。mark_stopped 多做一件事:把停止或出错前已经写出的部分回答存下来(场景 5):

async def mark_stopped(
    self,
    *,
    run_id: str,
    conversation_id: str,
    turn_id: str,
    status: RunStatus,
    error_code: str,
    error_message: str | None = None,
    partial_text: str | None = None,
) -> None:
    """Run 异常结束(failed / cancelled);只影响仍处于 running 的 Run。

    partial_text 是停止或出错前已经推给前端的回答。把它存成一条标记了 partial 的 assistant 消息:
    刷新页面后还能看到,用户接着发“继续”时模型也能看到自己写到了哪里。
    """
    async with self._transaction() as conn:
        await self._repo.lock_turn(conn, turn_id)  # 写消息要分配 Turn 内的序号,先锁 Turn
        if not await self._repo.finish_run(
            conn,
            run_id=run_id,
            status=status,
            error_code=error_code,
            error_message=error_message,
        ):
            return  # Run 已被其他路径结束,不再写任何东西
        if partial_text:
            await self._repo.insert_message(
                conn,
                message_id=new_id(),
                conversation_id=conversation_id,
                turn_id=turn_id,
                run_id=run_id,
                role="assistant",
                content=partial_text,
                metadata={"partial": True, "stop_reason": str(status)},
            )
        await self._repo.update_turn_status_for_run(
            conn, turn_id=turn_id, run_id=run_id, status=_TURN_STATUS_BY_RUN_STATUS[status]
        )

还有一个启动时调用的 recover_after_restart():Server 进程在执行中途退出后,那些执行会一直停在 running,新进程不可能接着执行它们。启动时把它们统一标为 failed(错误码 SERVER_RESTARTED),用户就可以对那一轮点重试。这个做法只适用于单实例部署。

8.7 按轮查询:list_turns

async def list_turns(self, *, user_id: str, conversation_id: str) -> list[TurnView]:
    """打开一个会话:按轮返回问题、回答、状态、审批卡片,以及最新一轮能否编辑重跑。"""
    async with self._repo.connection() as conn:
        await self._check_conversation_owner(conn, user_id=user_id, conversation_id=conversation_id)
        rows = await self._repo.list_turn_views(conn, conversation_id)

    turns = []
    for index, row in enumerate(rows):
        is_latest = index == len(rows) - 1
        pending = None
        if row["interrupt_id"] is not None:
            pending = PendingApproval(
                interrupt_id=row["interrupt_id"],
                reason=row["interrupt_type"],
                message=row["interrupt_payload"].get("message"),  # M3 审批节点写在 interrupt() 里的提示文案
            )
        turns.append(
            TurnView(
                id=row["id"],
                sequence_no=row["sequence_no"],
                status=row["status"],
                active_run_id=row["active_run_id"],
                question_message_id=row["question_message_id"],
                question=row["question"] or "",
                answer=row["answer"],
                error_code=row["error_code"],
                pending_approval=pending,
                is_latest=is_latest,
                # 正在执行的一轮要先点停止;pending(领了凭证还没执行)可以直接重跑,旧凭证会被作废
                can_rerun=is_latest and row["status"] != TurnStatus.RUNNING,
            )
        )
    return turns

SQL 部分在 Repository 里,一条语句把每一轮的问题、当前回答、失败原因、待审批的中断都查出来:

async def list_turn_views(self, conn: AsyncConnection, conversation_id: str) -> list[dict[str, Any]]:
    """会话详情:每一轮一行,带上问题、当前回答、失败原因和待审批的中断。

    - 问题:Turn 的 user_message_id 指向的消息;
    - 回答:这一轮最新一条仍然有效(status = 'completed')的 assistant 消息,
      编辑重跑后旧回答被标为 replaced,就不会再出现;
    - 待审批:只有当前生效的 Run 停在 interrupted 时,才取它 open 的 Interrupt。
    LEFT JOIN LATERAL 可以理解为“对左边的每一行执行一次右边的子查询”,这里用来取每轮最新的回答。
    """
    cur = await conn.execute(
        """
        SELECT t.id, t.sequence_no, t.status, t.active_run_id,
               q.id AS question_message_id, q.content AS question,
               a.content AS answer,
               r.error_code,
               i.id AS interrupt_id, i.interrupt_type, i.payload AS interrupt_payload
        FROM conversation_turn AS t
        LEFT JOIN conversation_message AS q ON q.id = t.user_message_id
        LEFT JOIN agent_run AS r ON r.id = t.active_run_id
        LEFT JOIN LATERAL (
            SELECT m.content FROM conversation_message AS m
            WHERE m.turn_id = t.id AND m.role = 'assistant' AND m.status = 'completed'
            ORDER BY m.sequence_no DESC
            LIMIT 1
        ) AS a ON TRUE
        LEFT JOIN agent_interrupt AS i
               ON i.run_id = r.id AND i.status = 'open' AND r.status = 'interrupted'
        WHERE t.conversation_id = %s AND t.deleted_at IS NULL
        ORDER BY t.sequence_no
        """,
        (conversation_id,),
    )
    return await cur.fetchall()

8.8 Repository 和几条值得细看的 SQL

文件:runs/repository.py

Repository 是数据访问层,相当于 Java 的 DAO:每个方法对应一条 SQL,把结果转换成 models.py 里的数据对象。每个方法的第一个参数都是 conn,由 Service 传进来,这样 Service 能把多个方法放进同一个事务。

方法名前缀有固定含义:get_、list_、find_ 是查询;lock_ 是带锁的查询,只能在事务里用;insert_、create_、set_、update_、replace_、cancel_、finish_、resolve_、claim_(抢占执行)、fail_(重启时把遗留的执行标为失败)是写入;next_*_sequence 分配序号。

先看 psycopg 的常用写法(只用来说明写法,不在项目里):

from psycopg.types.json import Jsonb

# 1. 从连接池借一个连接;离开 async with 块时自动归还
async with pool.connection() as conn:
    # 2. 执行 SQL:值一律用 %s 占位,参数按顺序放进元组,由驱动负责转义
    #    只有一个参数时元组要写成 (run_id,),逗号不能省
    cur = await conn.execute("SELECT id, status FROM agent_run WHERE id = %s", (run_id,))
    # 3. 取结果:连接池配置了 row_factory=dict_row,每行是 dict;没查到时返回 None
    row = await cur.fetchone()          # {"id": "...", "status": "queued"} 或 None
    rows = await cur.fetchall()         # list[dict]

# 4. 事务:块内的 SQL 要么全部提交,要么全部回滚
async with pool.connection() as conn, conn.transaction():
    # FOR NO KEY UPDATE 锁住这一行,直到事务结束;其他事务锁同一行时会等待(为什么不用 FOR UPDATE 见 8.3)
    await conn.execute("SELECT * FROM agent_run WHERE id = %s FOR NO KEY UPDATE", (run_id,))
    # RETURNING:写入的同时把结果行返回,省一次 SELECT
    cur = await conn.execute(
        "INSERT INTO conversation (id, user_id, title) VALUES (%s, %s, %s) RETURNING *",
        (conversation_id, user_id, title),
    )
    # dict 要用 Jsonb() 包装才能写入 JSONB 列
    await conn.execute(
        "UPDATE agent_interrupt SET resolution_payload = %s WHERE id = %s",
        (Jsonb({"approved": True}), interrupt_id),
    )
    # 5. 条件 UPDATE:rowcount 是实际修改的行数,0 表示 WHERE 条件没有命中
    cur = await conn.execute(
        "UPDATE agent_run SET status = 'running' WHERE id = %s AND status = 'queued'", (run_id,)
    )
    claimed = cur.rowcount == 1

需要特别注意 autocommit。连接池设置了 autocommit=True,这是 LangGraph 存档的要求,意思是每条 SQL 执行完立即单独提交。这种模式下如果不写 conn.transaction(),SELECT ... FOR NO KEY UPDATE 刚执行完锁就释放了。所以约定是:Repository 只接收 conn 、不开事务;需要事务的地方由 Service 用 conn.transaction() 包住。

下面几个方法各有一个值得弄懂的技巧。

抢占执行:条件 UPDATE。把“只有 queued 才能变成 running”写进 WHERE,数据库保证只有一个请求能命中:

async def claim_queued_run(self, conn: AsyncConnection, run_id: str) -> AgentRunRecord | None:
    """单条条件 UPDATE 原子抢占:只有 queued 的 Run 能变成 running,其余请求拿到 None。

    不能写成“先 SELECT 看是不是 queued,再 UPDATE”:两个请求可能同时看到 queued,都去执行。
    把条件写进 UPDATE 的 WHERE 里,数据库保证只有一个请求能命中这一行。
    """
    cur = await conn.execute(
        """
        UPDATE agent_run
        SET status = 'running', started_at = NOW(), updated_at = NOW()
        WHERE id = %s AND status = 'queued'
        RETURNING *
        """,
        (run_id,),
    )
    row = await cur.fetchone()
    return AgentRunRecord.from_row(row) if row else None

结束执行:只改仍在运行的。已经结束的执行不会被迟到的结果改写,rowcount 告诉调用方这次有没有改到:

async def finish_run(
    self,
    conn: AsyncConnection,
    *,
    run_id: str,
    status: RunStatus,
    last_checkpoint_id: str | None = None,
    error_code: str | None = None,
    error_message: str | None = None,
) -> bool:
    """把 running 的 Run 推进到终态;Run 已不是 running 时不覆盖,返回 False。

    COALESCE(新值, 旧值):传入 None 时保留原来的 last_checkpoint_id。
    """
    cur = await conn.execute(
        """
        UPDATE agent_run
        SET status = %s,
            last_checkpoint_id = COALESCE(%s, last_checkpoint_id),
            error_code = %s,
            error_message = %s,
            finished_at = NOW(),
            updated_at = NOW()
        WHERE id = %s AND status = 'running'
        """,
        (status, last_checkpoint_id, error_code, error_message, run_id),
    )
    return cur.rowcount == 1

编辑重跑:标记而不是删除:

async def replace_turn_messages(
    self, conn: AsyncConnection, *, turn_id: str, include_question: bool
) -> None:
    """编辑重跑前,把这一轮已有的回答(编辑时连同旧问题)标为 replaced。

    标记而不删除:页面和模型上下文只读 status = 'completed' 的消息,旧问答自然就看不到了,
    但数据库里仍然留着,排查时能看到这一轮被改过几次。
    """
    roles = ("user", "assistant") if include_question else ("assistant",)
    await conn.execute(
        """
        UPDATE conversation_message
        SET status = 'replaced', updated_at = NOW()
        WHERE turn_id = %s AND status = 'completed' AND role = ANY(%s)
        """,
        (turn_id, list(roles)),
    )

分配轮次序号:UPDATE ... RETURNING,一条语句完成“加一并返回新值”,同时更新会话的 updated_at,会话列表因此能按最近活动排序:

async def next_turn_sequence(self, conn: AsyncConnection, conversation_id: str) -> int:
    """原子递增会话的 Turn 序号并返回新值;调用前会话行已被锁定。"""
    cur = await conn.execute(
        """
        UPDATE conversation
        SET last_turn_seq = last_turn_seq + 1, updated_at = NOW()
        WHERE id = %s
        RETURNING last_turn_seq
        """,
        (conversation_id,),
    )
    row = await cur.fetchone()
    return row["last_turn_seq"]

8.9 数据库的五张表

文件:apps/devmind-server/migrations/ 下的 0001_add_agent_runtime_tables.sql 和 0002_turn_rerun_and_cleanup.sql

迁移文件按编号依次执行,每个文件第一句往 schema_migrations 表里写入自己的版本号,同一个文件执行两次会因为主键冲突整体回滚。已经执行过的迁移不要再改,改动一律写成新文件:0001 建表,0002 在它的基础上改了幂等索引、给 trigger_type 加上 edit,并删掉用不到的预留字段。这样已经跑过 0001 的数据库,只要再执行 0002 就能升级上来。

表之间的引用关系。物理外键由数据库强制约束,逻辑关联由 Service 在事务里保证:

conversation_turn.conversation_id   → conversation.id          物理外键
conversation_message.turn_id        → conversation_turn.id     物理外键
agent_run.turn_id                   → conversation_turn.id     物理外键
agent_interrupt.run_id              → agent_run.id             物理外键
conversation_turn.user_message_id   → conversation_message.id  逻辑关联:这一轮当前的问题
conversation_turn.active_run_id     → agent_run.id             逻辑关联:这一轮当前生效的执行
agent_run.parent_run_id             → agent_run.id             逻辑关联:恢复或重跑的来源执行
agent_run.graph_thread_id           → LangGraph 存档            逻辑关联

最核心的是执行表:

CREATE TABLE agent_run (
    id                      VARCHAR(64) PRIMARY KEY,
    user_id                 VARCHAR(64) NOT NULL,
    conversation_id         VARCHAR(64) NOT NULL,
    turn_id                 VARCHAR(64) NOT NULL
                            REFERENCES conversation_turn(id) ON DELETE RESTRICT,
    parent_run_id           VARCHAR(64),
    graph_thread_id         VARCHAR(64) NOT NULL,
    device_id               VARCHAR(128),
    source_checkpoint_id    VARCHAR(128),
    last_checkpoint_id      VARCHAR(128),
    interrupt_id            VARCHAR(128),
    idempotency_key         VARCHAR(128) NOT NULL,
    run_type                VARCHAR(32) NOT NULL
                            CHECK (run_type IN ('normal', 'resume', 'retry')),
    trigger_type            VARCHAR(32) NOT NULL
                            CHECK (trigger_type IN ('user_input', 'approval', 'retry')),
    status                  VARCHAR(32) NOT NULL DEFAULT 'queued'
                            CHECK (status IN ('queued', 'running', 'interrupted', 'succeeded', 'failed', 'cancelled')),
    turn_run_sequence       INTEGER NOT NULL CHECK (turn_run_sequence > 0),
    trace_id                VARCHAR(128),
    error_code              VARCHAR(64),
    error_message           TEXT,
    started_at              TIMESTAMPTZ,
    finished_at             TIMESTAMPTZ,
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (turn_id, turn_run_sequence)
);

trigger_type 的取值后来在 0002 里加上了 edit,下面的表是两个迁移都执行后的样子。

字段说明
idrun_id,同时是 AG-UI 的 runId
user_id、conversation_id、turn_id属于谁、哪个会话、哪一轮
parent_run_id审批恢复或重跑的来源执行
graph_thread_id这次执行使用的存档链
device_id本次执行允许调用的 Desktop 设备,下篇使用
source_checkpoint_id、last_checkpoint_id从哪个存档开始、结束在哪个存档,用于排查
interrupt_idResume 执行正在处理的中断
idempotency_key前端生成的幂等键
run_typenormal / resume / retry
trigger_typeuser_input / approval / retry / edit
statusqueued / running / interrupted / succeeded / failed / cancelled
turn_run_sequence这是这一轮的第几次执行
error_code、error_message失败原因

约束和索引,先看 0001 里的:

-- 幂等:同一会话内同一个幂等键只能创建一个 Run(重复点击、网络重发都返回已有 Run)。
CREATE UNIQUE INDEX uq_agent_run_idempotency
    ON agent_run (conversation_id, idempotency_key);
-- 并发保护:同一条 Graph 状态链同一时刻最多一个 queued/running Run,避免两个 Run 同时写 Checkpoint。
CREATE UNIQUE INDEX uq_agent_run_active_graph_thread
    ON agent_run (graph_thread_id)
    WHERE status IN ('queued', 'running');
-- 并发保护:同一条 Graph 状态链最多一个 open Interrupt。
CREATE UNIQUE INDEX uq_agent_interrupt_open_graph_thread
    ON agent_interrupt (graph_thread_id)
    WHERE status = 'open';

再看 0002 做的改动,幂等索引从按会话判重改成按用户判重(原因见 8.3 节):

-- 0002:支持编辑重跑最新一轮、新会话首问的幂等,并删掉 v0.1 用不到的预留字段。
-- 执行方式:psql --single-transaction -v ON_ERROR_STOP=1,任何一句失败都会整体回滚。
-- 已经执行过的迁移不要再改:改动一律写成新的迁移文件,已有的数据库才能按顺序升级上来。

INSERT INTO schema_migrations (version) VALUES ('0002_turn_rerun_and_cleanup');

-- 幂等键改为按“用户 + 键”判重:新会话的第一问还没有 conversation_id,重发时只能靠 user_id + 幂等键找回。
DROP INDEX uq_agent_run_idempotency;
CREATE UNIQUE INDEX uq_agent_run_idempotency
    ON agent_run (user_id, idempotency_key);

-- 触发类型增加 edit:用户编辑了最新一轮的问题后重跑。
ALTER TABLE agent_run DROP CONSTRAINT agent_run_trigger_type_check;
ALTER TABLE agent_run ADD CONSTRAINT agent_run_trigger_type_check
    CHECK (trigger_type IN ('user_input', 'approval', 'retry', 'edit'));

-- 删掉 v0.1 用不到的预留字段:编辑重跑在同一轮内完成,不再需要“替换轮次”;历史压缩和软删除原因以后再加。
ALTER TABLE conversation DROP COLUMN summary, DROP COLUMN summary_through_seq;
ALTER TABLE conversation_turn DROP COLUMN replaces_turn_id, DROP COLUMN delete_reason;
约束或索引作用
uq_agent_run_idempotency同一用户的同一个幂等键只能创建一个执行
uq_agent_run_active_graph_thread同一条存档链同一时刻最多一个 queued 或 running 的执行,防止两个执行同时写同一条存档链
uq_agent_interrupt_open_graph_thread同一条存档链最多一个 open 的中断
UNIQUE (conversation_id, sequence_no) 等轮次序号、消息序号、执行序号不重复

后两个唯一索引带 WHERE 条件,叫部分唯一索引,只约束满足条件的行。例如同一条存档链上可以有很多已经结束的执行,但同一时刻只能有一个在排队或执行。这些约束是最后一道防线,主要手段是 Service 里的锁。

8.10 api 层、依赖注入和启动装配

请求和响应模型(api/schemas/runs.py):Pydantic 模型在请求到达时自动校验,校验失败直接返回 422。页面上一轮对应的响应:

class TurnResponse(BaseModel):
    """页面上的一轮:一问一答,加上状态和可做的操作。"""

    model_config = ConfigDict(from_attributes=True)

    id: str
    sequence_no: int
    status: str
    active_run_id: str | None
    question_message_id: str | None
    question: str
    answer: str | None
    error_code: str | None
    pending_approval: PendingApprovalResponse | None
    is_latest: bool
    can_rerun: bool

路由(api/conversations.py、api/runs.py):只负责解析 HTTP 输入、注入当前用户、调用 Service,不直接访问数据库。以编辑重跑为例:

@router.post("/turns/rerun", response_model=PreparedRunResponse)
async def rerun_turn(
    body: RerunTurnRequest, user: CurrentUserDep, service: RunServiceDep
) -> PreparedRunResponse:
    """准备:编辑或重试最新一轮。复用这一轮,新建 Run;只能对会话中最新的一轮操作。"""
    prepared = await service.prepare_rerun(
        user_id=user.id,
        turn_id=body.turn_id,
        content=body.content,
        idempotency_key=body.idempotency_key,
    )
    return PreparedRunResponse.model_validate(prepared)

依赖注入(api/dependencies.py):路由参数上写 user: CurrentUserDep、service: RunServiceDep,FastAPI 在调用路由之前先执行对应的依赖函数,把结果传进来。当前用户集中在一个函数里,v0.2 接入真实登录时只改这一处:

def get_current_user(x_dev_user: Annotated[str | None, Header()] = None) -> CurrentUser:
    """开发期身份依赖:只有开启 development_mode 才接受 X-Dev-User,生产环境直接 401。"""
    settings = get_settings()
    if not settings.development_mode:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication is not configured")
    return CurrentUser(id=x_dev_user or settings.dev_user_id)

异常映射(api/errors.py):Service 只抛业务异常,这里统一转成 {code, message} 格式的 HTTP 错误:

异常错误码HTTP
会话、轮次、执行、中断不存在CONVERSATION_NOT_FOUND、TURN_NOT_FOUND、RUN_NOT_FOUND、INTERRUPT_NOT_FOUND404
访问别人的数据RUN_FORBIDDEN403
编辑的不是最新一轮TURN_NOT_LATEST409
这一轮正在执行RUN_IN_PROGRESS409
最新一轮在等待审批,不能提新问题TURN_WAITING_APPROVAL409
审批卡片已经处理过、已作废,或者同一个键提交了相反的选择INTERRUPT_ALREADY_RESOLVED409
审批的来源执行已经不在等待审批INVALID_RESUME_TARGET409
同一个幂等键用在了别的请求上IDEMPOTENCY_KEY_REUSED409
同一个执行被启动两次、触发唯一约束RUN_CONFLICT409
执行请求和数据库记录对不上RUN_INPUT_MISMATCH400
没有预料到的异常(代码 bug、数据库报错等)INTERNAL_ERROR500

最后一行由 UnhandledErrorMiddleware 处理。没有它的话,未处理的异常会被 Starlette 在所有中间件的最外面转成 500,这个响应不经过 CORS 中间件、没有跨域头,浏览器拿不到它,前端只能看到一句“Failed to fetch”,看不出其实是 Server 出错了。所以在 CORS 中间件里面再包一层,把异常转成 JSON 响应,跨域头就能正常加上:

class UnhandledErrorMiddleware:
    """把没有预料到的异常转成 500 JSON,而不是让它一路抛到最外层。

    Starlette 对未处理异常的兜底在所有中间件的最外面,生成的 500 响应不经过 CORSMiddleware,
    没有跨域响应头。浏览器拿不到这个响应,前端只能看到“Failed to fetch”,看不出是 Server 出错了。
    这个中间件放在 CORSMiddleware 里面:在这里把异常转成响应,CORS 头就能正常加上。

    写成纯 ASGI 中间件,而不是 @app.middleware("http"):后者对流式响应的取消处理有问题,会影响 SSE。
    响应已经开始发送(例如 SSE 流到一半)之后再出错,就没法改成 500 了,只能继续往外抛。
    """

    def __init__(self, app: ASGIApp):
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        started = False
        client_gone = False  # 往客户端写数据时失败:连接已经断了

        async def send_wrapper(message: Message) -> None:
            nonlocal started, client_gone
            if message["type"] == "http.response.start":
                started = True
            try:
                await send(message)
            except OSError:
                client_gone = True
                raise

        try:
            await self.app(scope, receive, send_wrapper)
        except Exception as exc:
            if client_gone or isinstance(exc, ClientDisconnect):
                # 客户端断开是正常情况(点了停止、关了窗口),也没法再给它发响应,记一行就够了
                logger.info("client disconnected %s %s", scope.get("method"), scope.get("path"))
                return
            logger.exception("unhandled error %s %s", scope.get("method"), scope.get("path"))
            if started:
                raise
            response = JSONResponse(
                status_code=500, content={"code": "INTERNAL_ERROR", "message": "服务器内部错误,请查看 Server 日志"}
            )
            await response(scope, receive, send)

客户端断开不算未处理的异常:往客户端写数据时失败(OSError),或者读请求时发现连接已断(ClientDisconnect),说明用户点了停止或关了窗口,这时也没法再给它发响应,只记一行 client disconnected,不打印错误堆栈。

启动装配(main.py):Python 没有 Spring 那样的自动注入,所有全局只有一个的对象都在 lifespan 里按依赖顺序手动创建,挂到 app.state 上,dependencies.py 再从那里取出来:

@asynccontextmanager
async def lifespan(app: FastAPI):
    """唯一的装配入口:启动时创建连接池、Graph、Repository、Service 和 Adapter,关闭时逆序释放。"""
    settings = get_settings()
    # 一个 devmind_pool 同时服务业务 Repository 和 Checkpointer;两者操作的表互不交叉
    devmind_pool = AsyncConnectionPool(
        conninfo=settings.database_url,
        min_size=1,
        max_size=10,
        open=False,
        kwargs={
            "autocommit": True,  # Checkpointer 要求;业务事务由 conn.transaction() 显式开启
            "prepare_threshold": 0,  # 关闭服务端预编译语句,Checkpointer 要求(兼容连接池中间件)
            "row_factory": dict_row,  # 查询结果每行是 dict,可以按列名取值
        },
    )
    await devmind_pool.open()
    try:
        await check_migrations(devmind_pool)  # 表结构和代码对不上时直接拒绝启动,而不是等接口出错
        checkpointer = AsyncPostgresSaver(devmind_pool)
        await checkpointer.setup()  # 只创建 LangGraph 自己的表;业务表由迁移文件创建
        graph = create_langgraph_runtime(checkpointer)
        official_agent = LangGraphAgent(
            name="devmind",
            graph=graph,
            config={"recursion_limit": 40},
            emit_raw_events=False,
            emit_interrupt_outcome=True,  # 用 RUN_FINISHED.outcome 输出标准 Interrupt
            enable_legacy_on_interrupt_event=False,
        )
        run_repository = RunRepository(devmind_pool)
        run_service = RunService(run_repository)
        # 单实例部署:上个进程遗留的 running Run 不可能继续,启动时先收尾
        await run_service.recover_after_restart()

        app.state.devmind_pool = devmind_pool
        app.state.run_service = run_service
        app.state.ag_ui_adapter = DevMindAgUiAdapter(
            agent=official_agent, graph=graph, repository=run_repository, service=run_service
        )
        yield
    finally:
        # 先让新请求拿不到运行时对象(依赖会返回 503),再关闭连接池
        app.state.ag_ui_adapter = None
        app.state.run_service = None
        app.state.devmind_pool = None
        await devmind_pool.close()

连接池同时服务业务表和 LangGraph 的存档,三个参数都是存档的要求:autocommit=True、prepare_threshold=0、row_factory=dict_row。

连接池打开后的第一件事是 check_migrations:migrations/ 目录里的文件,只要有一个没在 schema_migrations 里登记,就拒绝启动,并打印要执行的命令。表结构和代码对不上的问题,不用等到某个接口执行到对应的 SQL 才暴露:

async def check_migrations(pool: AsyncConnectionPool) -> None:
    """migrations/ 目录里有、但 schema_migrations 表里没有记录的迁移,视为未执行。"""
    if not MIGRATIONS_DIR.is_dir():
        return  # 部署时没有带上迁移目录,就不做检查
    expected = sorted(path.stem for path in MIGRATIONS_DIR.glob("*.sql"))
    async with pool.connection() as conn:
        exists = await (await conn.execute("SELECT to_regclass('schema_migrations') IS NOT NULL AS ok")).fetchone()
        applied = set()
        if exists["ok"]:
            rows = await (await conn.execute("SELECT version FROM schema_migrations")).fetchall()
            applied = {row["version"] for row in rows}
    missing = [version for version in expected if version not in applied]
    if missing:
        commands = "\n".join(
            f"  docker exec -i devmind-postgres-1 psql -U devmind -d devmind -v ON_ERROR_STOP=1 "
            f"--single-transaction < apps/devmind-server/migrations/{version}.sql"
            for version in missing
        )
        raise RuntimeError(f"数据库还没有执行这些迁移:{', '.join(missing)}。在仓库根目录按顺序执行:\n{commands}")

8.11 前端:useTurnRunner

文件:hooks/useTurnRunner.ts

页面上三个会执行 Agent 的操作都在这里,都是“先准备、再执行”:

// 两步走的公共部分:第一步失败时直接显示错误,不再执行第二步
async function prepareThenExecute(
  key: string,
  prepare: () => Promise<PreparedRun>,
  question: (prepared: PreparedRun) => string | null,
  resume?: ResumeEntry[]
): Promise<void> {
  const current = session(key)
  if (current.inFlight) return // 这个会话上一个操作还没结束(例如双击“允许”),忽略这次点击
  if (running.current.size >= MAX_CONCURRENT_RUNS) {
    update(key, () => ({
      error: `同时执行的会话最多 ${MAX_CONCURRENT_RUNS} 个,请等其中一个结束,或者先停止一个`
    }))
    return
  }
  current.inFlight = true
  running.current.add(key)
  let runKey = key // 新会话的第一问,第一步返回后换成真正的会话 ID
  update(key, () => ({ error: '', status: 'PREPARING' })) // 第一步期间也算忙:按钮禁用,避免重复提交
  try {
    let prepared: PreparedRun
    try {
      prepared = await prepare()
    } catch (err) {
      update(key, () => ({
        error: err instanceof Error ? err.message : String(err),
        status: 'FAILED'
      }))
      return
    }
    runKey = prepared.conversation_id
    moveKey(key, runKey)
    await onPrepared(prepared, key)
    await execute(runKey, prepared, question(prepared), resume)
  } finally {
    session(runKey).inFlight = false
    running.current.delete(runKey)
  }
}

// 场景 1、2:提一个新问题;conversationId 为 null 时 Server 顺带创建会话
const ask = (key: string, conversationId: string | null, content: string): Promise<void> =>
  prepareThenExecute(
    key,
    () => createTurn({ conversationId, content, idempotencyKey: randomUUID() }),
    () => content
  )

// 场景 4:编辑或重试最新一轮;content 不传表示原样重试
const rerun = (key: string, turn: Turn, content?: string): Promise<void> =>
  prepareThenExecute(
    key,
    () => rerunTurn({ turnId: turn.id, content, idempotencyKey: randomUUID() }),
    () => content ?? turn.question
  )

// 场景 3:点允许或拒绝。Server 用数据库里的审批结果恢复,这里带上 resume 只是为了符合 AG-UI 协议
const approve = (key: string, interruptId: string, approved: boolean): Promise<void> =>
  prepareThenExecute(
    key,
    () => resumeRun({ interruptId, approved }),
    () => null,
    [{ interruptId, status: 'resolved', payload: { approved } }]
  )

// 停止某个会话的执行:断开它的 SSE;Server 捕获断开后把 Run 标为 cancelled,并停止后台的模型调用
const stop = (key: string): void => {

执行状态按会话分开存放。多个会话可以同时执行,所以执行状态不能是整个页面一份。useTurnRunner 用一个以会话 ID 为键的对象保存每个会话的状态(执行阶段、正在执行的轮次、流式文字、工具卡片、错误);另用一个 ref 里的 Map 保存每个会话一份、不需要触发重新渲染的东西:这次用的 HttpAgent、停止标记 stopRequested、防重复点击的 inFlight。执行本身是一个独立的异步过程,不属于哪个页面组件,事件到了就写进它自己会话的那一份;页面显示哪个会话,就读哪个会话的那一份(viewOf(key))。所以切换会话不影响正在执行的会话,停止按钮也只停当前会话的那一次。

新会话的第一问还没有 conversation_id。页面给“新会话”分配一个草稿键(draft: 加随机 ID,每点一次“新会话”换一个),执行状态先记在草稿键下;第一步返回后,挪到真正的会话 ID 下。第一步返回时,只有用户还停在这个草稿上,页面才切到新会话;已经切走了就不动,执行在后台继续。页面判断“现在显示的是哪个”要读 ref 里的最新值,不能用发起时闭包里的旧值:

const runner = useTurnRunner({
  // 第一步完成。新会话的第一问:用户还停在这个“新会话”页面,就切到 Server 刚创建的会话;
  // 已经切走了就不动,执行在后台继续。已有会话:正在显示就先把这一轮刷新出来
  onPrepared: async (prepared: PreparedRun, key: string) => {
    const now = showing.current
    if (now.selectedId === null && now.draftKey === key) {
      select(prepared.conversation_id)
    } else if (now.selectedId === prepared.conversation_id) {
      await reloadTurns(prepared.conversation_id)
    }
    await conversations.reload()
  },
  // 第二步结束:读取落库后的问题、回答和审批卡片。轮次按会话缓存,不在显示的会话也刷新,切回来就是最新的
  onSettled: async (conversationId: string, turnId: string) => {
    await reloadTurns(conversationId, turnId)
    await conversations.reload()
  }
})

const view = runner.viewOf(currentKey) // 当前会话的执行状态;别的会话在执行不影响这里

轮次也按会话缓存(useTurns):别的会话执行结束时也会刷新它自己的轮次,结果只写进它自己的那一份,不会覆盖正在显示的会话;切回某个会话时先显示缓存,再读最新的。

同时执行的上限是 4 个。每个执行中的会话占用一个 SSE 连接,而浏览器(Electron 的 Renderer 也一样)对同一个 Server 地址最多同时开 6 个 HTTP/1.1 连接。6 个都被 SSE 占满后,切换会话要发的普通请求也会排队,页面看起来像卡死了。所以上限定为 4 个,给普通请求留 2 个;实测 4 个同时执行时,切换会话读取轮次仍然只要 100 毫秒左右。以后 Server 支持 HTTP/2,一个连接能同时承载多个请求,就没有这个限制了:

// 同时执行的会话上限。每个执行中的会话占用一个 SSE 连接,而浏览器对同一个 Server 地址
// 最多同时开 6 个 HTTP/1.1 连接;占满之后,切换会话要发的普通请求也会排队卡住。留 2 个给普通请求
export const MAX_CONCURRENT_RUNS = 4

prepareThenExecute 一开始就把这个会话的状态设为 PREPARING,从第一步开始这个会话的 busy 就是 true:它的发送、允许、拒绝、编辑、重试按钮全部禁用,直到第二步结束;别的会话不受影响。只靠状态还不够:状态要等下一次渲染才生效,快速双击时第二次点击还看不到新状态,所以另用这个会话的 inFlight 标记同步拦截。

停止按钮从第二步的请求发出(CONNECTING)起就可以点,只停当前会话,不用等收到第一个事件:模型迟迟没开始时,用户也能取消。这时断开连接,Server 要么走 stream() 的取消分支,要么走上面 abandon() 的兜底,结果都是这一轮记为已停止。

每次执行新建一个 HttpAgent。@ag-ui/client 的 HttpAgent 会记住上一次停在审批的中断(pendingInterrupts)。下一次执行时,如果请求的 resume 里没有回应这个中断,它在发请求之前就直接报错。整个页面共用一个实例的话,停在审批后只要没点允许或拒绝,编辑这一轮、切到别的会话提问、新建会话提问全部失败,而且一直失败到刷新页面(在真实界面里实测过)。会话、历史和审批状态都以 Server 为准,Server 自己会校验 resume 里的中断 ID,客户端不需要跨执行记住任何东西,所以每次执行都新建一个:

import { HttpAgent } from '@ag-ui/client' // 导入官方 HTTP + SSE Agent 客户端

import { devmindHeaders } from './devmind-api' // 与准备接口共用开发期身份头

const apiBaseUrl = import.meta.env.VITE_AGENT_API_URL ?? 'http://127.0.0.1:8000' // 读取 Agent Server 地址并提供开发期默认值

// 每次执行新建一个 HttpAgent,不在整个页面共用一个。
// HttpAgent 会记住上一次停在审批的中断(pendingInterrupts),下一次执行如果没有在 resume 里回应它,
// 就在发请求之前直接报错。共用一个实例时,停在审批后编辑这一轮、或者切到别的会话提问都会失败。
// 会话、历史和审批状态都以 Server 为准,客户端不需要跨执行记住任何东西。
export function createDevmindAgent(conversationId: string): HttpAgent {
  return new HttpAgent({
    agentId: 'devmind',
    threadId: conversationId, // AG-UI threadId 固定等于 conversation_id
    url: `${apiBaseUrl}/api/agent`, // 执行接口(第二步)
    headers: devmindHeaders
  })
}

第二步的执行函数,开头新建这次用的 HttpAgent(threadId 是会话 ID),runId 设成 Server 创建的执行:

async function execute(
  key: string,
  prepared: PreparedRun,
  question: string | null,
  resume?: ResumeEntry[]
): Promise<void> {
  const current = session(key)
  const agent = createDevmindAgent(prepared.conversation_id) // 每次执行一个新实例,原因见 devmind-agent.ts
  current.agent = agent
  current.stopRequested = false
  // 历史以数据库为准,Server 只校验最后一条用户消息,所以这里只带本轮问题;审批恢复不带消息
  agent.messages =
    question === null ? [] : [{ id: prepared.user_message_id!, role: 'user', content: question }]
  update(key, () => ({
    activeTurnId: prepared.turn_id,
    streamingText: '',
    tools: [],
    status: 'CONNECTING'
  }))
  let ended = false // 是否收到了终态事件(RUN_FINISHED 或 RUN_ERROR)
  try {
    await agent.runAgent(
      { runId: prepared.run_id, resume }, // runId 必须使用 Server 创建的 run_id

怎么判断这次执行结束了。正常情况下,结束的标志是 RUN_FINISHED(完成或停在审批)或 RUN_ERROR(Server 报错)。但用户点停止时,@ag-ui/client 1.0 的表现和直觉不一样:

  • runAgent() 不会抛出 AbortError,而是正常返回,也不会触发 RUN_FINISHED;
  • 在浏览器(也就是 Electron 的 Renderer)里,它还会自己合成一个 RUN_ERROR 事件,消息是“BodyStreamBuffer was aborted”;在 Node 里则是触发 onRunFailed。

如果按直觉写成“捕获 AbortError 就算停止”,那段代码永远走不到,状态会一直停在 RUNNING,这个会话的按钮都被禁用。所以点停止时先给这个会话记一个标记 stopRequested,之后客户端库报出来的中止错误一律忽略;runAgent() 返回后,如果没收到终态事件,就按这个标记判断是“已停止”还是“意外中断”:

      onRunFinishedEvent(params) {
        ended = true
        const status: RunStatus =
          params.outcome === 'interrupt'
            ? 'INTERRUPTED'
            : params.outcome === 'cancelled'
              ? 'CANCELLED'
              : 'SUCCEEDED'
        update(key, () => ({ status }))
      },
      onRunErrorEvent({ event }) {
        if (current.stopRequested) return // 用户点了停止:客户端库合成的中止错误,不是 Server 的错误
        ended = true
        update(key, () => ({ error: event.message, status: 'FAILED' })) // Server 返回的脱敏错误信息
      },
      onRunFailed({ error: failure }) {
        if (current.stopRequested) return // 用户点了停止:中止请求引起的失败不算错误
        update(key, () => ({ error: failure.message })) // 连接、解析或 4xx 失败
      }
    }
  )
  if (!ended) {
    // 没收到终态事件就结束了:要么是用户点了停止,要么是连接或事件流意外中断
    update(key, () => ({ status: current.stopRequested ? 'CANCELLED' : 'FAILED' }))
  }
} catch (err) {
  const stopped = current.stopRequested
  update(key, () => ({
    status: stopped ? 'CANCELLED' : 'FAILED',
    ...(stopped ? {} : { error: err instanceof Error ? err.message : String(err) })
  }))
} finally {
  current.agent = null
  update(key, () => ({ activeTurnId: null, streamingText: '' }))
  // 正常结束时 Server 在发出 RUN_FINISHED 之前已经落库;停止时 Server 还在收尾,onSettled 会等这一轮结束
  await onSettled(prepared.conversation_id, prepared.turn_id)

结束后等 Server 收尾。执行结束后要重新读取轮次,以 Server 落库的结果为准。正常完成时,Server 在发出 RUN_FINISHED 之前已经落库,读到的就是最终结果;但点停止后前端马上就来读,Server 这时往往还在停止后台任务、保存半截回答。实测断开后立刻读到的都是 running,要再过 20~60 毫秒才变成 cancelled。所以 reload 可以指定等哪一轮:那一轮还是 running 或 pending 时,隔 100 毫秒再读,最多等 2 秒:

const reload = useCallback(
  async (id: string, waitForTurnId?: string) => {
    try {
      for (let attempt = 1; ; attempt++) {
        const turns = await listTurns(id)
        store(id, turns)
        const waiting = turns.find((turn) => turn.id === waitForTurnId)
        const settled = !waiting || (waiting.status !== 'running' && waiting.status !== 'pending')
        if (settled || attempt >= SETTLE_MAX_TRIES) break
        await sleep(SETTLE_POLL_MS)
      }
    } catch (err) {
      fail(id, err)
    }
  },
  [store, fail]
)

最多等 2 秒是因为有一种情况不会结束:请求还没到 Server 就被停止了,执行一直是 queued,这一轮停在“等待执行”。这时照常显示,用户可以点重试,或者直接提新问题(旧凭证会被作废)。

TurnCard 根据 Server 返回的 can_rerun 决定是否显示“编辑”和“重试”,根据 pending_approval 决定是否显示审批卡片,前端不自己判断规则。

9. 测试、运行和常见问题

9.1 测试

文件:tests/test_m4a_runs.py、tests/scripted_model.py

测试直接走真实的 FastAPI、PostgreSQL、LangGraph 和官方 AG-UI 集成,只把模型换成按脚本返回的假模型:往 client.script 追加一条 AIMessage,模型下一次被调用时就返回它;带 tool_calls 的 AIMessage 可以模拟模型决定调用工具。测试按场景组织:

测试覆盖什么
test_scenario1_first_turn_creates_conversation不带会话 ID 自动建会话、响应丢失后重发拿回同一个结果、执行接口返回的 threadId 是会话 ID、同一个执行不能启动两次、会话列表和按轮查询
test_scenario2_follow_up_turn_sees_history新一轮用新存档链但能看到上一轮、只有最新一轮能编辑、会话列表按最近活动排序、threadId 不对返回 400
test_scenario3_approval_continues_in_same_turn审批卡片出现在最新一轮、只用中断 ID 就能恢复、重复点击幂等、同一个键换成相反的选择返回 409、同一张卡片不能处理两次、两步之间这一轮回到 pending、批准后工具真正执行、同一轮同一条存档链
test_approval_and_edit_at_the_same_time同一轮上同时点允许和编辑,连续 5 次都不死锁:编辑总能成功,审批要么成功要么 409
test_scenario4_edit_latest_turn历史轮次不能编辑、编辑后同一轮新问题新执行新存档链、旧问答标为 replaced 且不进上下文
test_scenario4_edit_first_turn_updates_title编辑第一轮时会话标题跟着改
test_scenario4_retry_rules执行中不能重跑、重启后失败的轮次可以原样重试、等待审批时编辑会作废审批
test_ownership_and_not_found403、404、422
test_concurrent_first_turn_with_same_key_creates_one_conversation同一个幂等键并发 5 个首问只建一个会话
test_idempotency_key_scope幂等键被用在别的会话返回 409,另一个用户用同样的键互不影响
test_unique_violation_maps_to_409绕过锁触发唯一约束时返回 409,整个事务回滚
test_scenario5_stop_then_continue回答到一半停止:半截回答落库、这一轮显示已停止;发“继续”新增一轮,模型看到上一轮的问题、半截回答和中断说明
test_scenario5_retry_replaces_partial_answer停止后点重试,半截回答被新回答替换
test_model_timeout_keeps_partial_answer模型写到一半卡住超时:事件符合协议、半截回答保存、这一轮记为失败,接着发“继续”能接上
test_disconnect_before_stream_starts抢占执行之后、推流开始之前就断开(ASGI 2.3 和 2.4 各一次):这一轮记为已停止,可以重试,也能直接提新问题
test_new_turn_rules_depend_on_latest_turn领了凭证没执行的一轮在提新问题时被作废;执行中或等待审批时提新问题返回 409
test_two_conversations_run_at_the_same_time两个会话同时执行:A 执行到一半时 B 照常完成、A 里再提问返回 409、停止 A 不影响 B、会话列表带上最新一轮的状态
test_unhandled_error_returns_500_with_cors没有预料到的异常返回 500 JSON,并且带跨域头
test_startup_refuses_unapplied_migrations少执行了迁移时,启动检查报错并列出缺少的迁移

测试开始时会清空业务表,一定要用单独的测试库。 第一次先建测试库并执行迁移(在仓库根目录):

docker exec -i devmind-postgres-1 psql -U devmind -d devmind -c "CREATE DATABASE devmind_test"
for f in apps/devmind-server/migrations/*.sql; do
  docker exec -i devmind-postgres-1 psql -U devmind -d devmind_test -v ON_ERROR_STOP=1 --single-transaction < "$f"
done

然后在 apps/devmind-server 目录下运行:

DATABASE_URL=postgresql://devmind:devmind@127.0.0.1:5432/devmind_test uv run pytest tests/test_m4a_runs.py -v

9.2 跑起来

开发库已经执行过 0001 的,只需要再执行 0002(在仓库根目录):

docker exec -i devmind-postgres-1 psql -U devmind -d devmind -v ON_ERROR_STOP=1 --single-transaction \
  < apps/devmind-server/migrations/0002_turn_rerun_and_cleanup.sql

全新的数据库就按编号把两个文件都执行一遍。少执行了哪个,Server 启动时会直接报错并提示要执行的命令。

启动 Server 和 Desktop:

cd apps/devmind-server
uv run fastapi dev src/devmind_server/main.py --host 127.0.0.1 --port 8000
cd apps/devmind-desktop
pnpm dev

然后按第 6 章的顺序在界面上走一遍:新会话里说“我叫小明”;再问“我叫什么”;让它“调用 demo_text 工具读取示例文本”并点允许;编辑最新一轮再运行;点开左边另一个会话再切回来;让它写一段长回答,中途点停止,确认半截回答还在,再发“继续”,确认它接着往下写。每一步都可以到数据库里看看记录变成了什么样:

SELECT t.sequence_no, r.turn_run_sequence, r.run_type, r.trigger_type, r.status, r.graph_thread_id
FROM agent_run r JOIN conversation_turn t ON t.id = r.turn_id
WHERE r.conversation_id = '替换成会话ID'
ORDER BY t.sequence_no, r.turn_run_sequence;

SELECT t.sequence_no, m.role, m.status, left(m.content, 30)
FROM conversation_message m JOIN conversation_turn t ON t.id = m.turn_id
WHERE m.conversation_id = '替换成会话ID'
ORDER BY t.sequence_no, m.sequence_no;

9.3 常见问题

所有接口都返回 401。Server 没有开启开发模式,检查 .env 中的 DEVMIND_DEVELOPMENT_MODE=true。

Server 启动时报“数据库还没有执行这些迁移” 。按报错里给的命令,把缺少的迁移依次执行一遍。

前端报“连接 Server 失败” 。Server 没启动,或者刚改过代码后重启失败,看一下 Server 的日志。如果是 500 错误,前端会显示 INTERNAL_ERROR,同样去 Server 日志里找异常堆栈。

编辑或提问时返回 409 RUN_IN_PROGRESS。最新一轮还在执行,先点停止。

提问时返回 409 TURN_WAITING_APPROVAL。最新一轮在等待审批,先点允许或拒绝,或者编辑这一轮。

停止后发“继续”,AI 为什么知道从哪接着写?停止时已经写出的部分被存了下来,新一轮的上下文里带着它,并且补了一句说明:这一轮没有完成,如果用户让你继续,从中断的地方接着写。见场景 5。模型通常能接上,但这是提示词引导,不是硬性保证。

执行时报错“Thread has 1 pending interrupt(s) not addressed by resume” 。这是 @ag-ui/client 在发请求之前自己报的错:同一个 HttpAgent 上一次停在了审批,这一次又没有回应那个中断。前端必须每次执行新建一个 HttpAgent,见 8.11 节。

点停止后页面一直显示“执行中”,按钮都不能点。前端没认出这次执行是被停止的。@ag-ui/client 停止后不会抛出 AbortError,要用 stopRequested 标记判断,见 8.11 节。

页面提示“模型长时间没有输出,已停止” 。模型连续 60 秒没有任何新内容(包括思考过程),或者单次调用超过 10 分钟。已经写出的部分已经保存,可以点重试,或者直接发“继续”。见 7.9 节。

提示“同时执行的会话最多 4 个” 。已经有 4 个会话在执行,等其中一个结束或者先停止一个。上限的原因见 8.11 节。

编辑时返回 409 TURN_NOT_LATEST。只能编辑会话中最新的一轮。

点审批返回 409 INTERRUPT_ALREADY_RESOLVED。这张审批卡片已经处理过,或者这一轮已经被编辑重跑、审批被作废了。刷新页面即可。

/api/agent 返回 409。这次执行已经开始过。每次执行都必须先调准备接口领取新的 run_id。

/api/agent 返回 400 RUN_INPUT_MISMATCH。检查 HttpAgent.threadId 是否等于会话 ID;提问和重跑时,最后一条用户消息的 ID 是否用的是准备接口返回的 user_message_id、内容是否一致;审批恢复时,resume[].interruptId 是否就是卡片上的中断 ID。

准备接口返回 409,消息是 unique constraint violated: ... 。某条唯一约束被触发,说明有并发写入绕过了锁,优先检查最近改动的 Service 代码是否漏了加锁。

为什么审批和编辑都不新增轮次?页面上一轮就是一问一答。审批是对同一个问题的继续处理,编辑是对同一个位置的问题的替换,都不是新问题。

10. 读完之后

10.1 检验自己是否真的懂了

  1. 不看代码,能说出各个场景各自调了哪几个接口、数据库里各新增或修改了哪几行。
  2. 不看代码,能在白纸上画出第 7 章执行接口的调用栈。
  3. 不借助 AI,能改一个行为并让测试通过。

下面这些问题要搞懂:

  • 为什么一轮和一次执行要分成两张表?
  • 为什么会让 Agent 执行的操作都分两步?合成一步会有什么问题?
  • 幂等键为什么由前端生成?为什么按用户判重?为什么先锁幂等键再查?
  • 审批恢复为什么沿用原存档链,编辑重跑为什么新开一条?
  • 新问题用的是新存档链,为什么还能记住上一轮说的话?
  • 编辑重跑时,为什么旧问答是标记为 replaced 而不是删除?
  • 停止后点“重试”和直接发“继续”有什么区别?为什么不做“从断点接着执行”?
  • 为什么要先落库,再把 RUN_FINISHED 发给前端?
  • 所有事务为什么要按同样的顺序加锁?行锁为什么用 FOR NO KEY UPDATE 而不是 FOR UPDATE?

10.2 动手改一个功能

推荐做“会话重命名”:新增 POST /api/conversations/rename,body 是 conversation_id 和 title,只能改自己的会话;左侧会话栏双击标题可以改名。它从路由、Service、Repository 到前端每一层都要动,还要自己写一个测试。

提示:Repository 加一条 UPDATE;Service 加一个方法,先用 _check_conversation_owner 校验所有权(写操作要放进 _transaction);schemas/runs.py 加请求模型;api/conversations.py 加路由;前端在 devmind-api.ts 加函数,在 ConversationSidebar.tsx 加编辑状态。

10.3 验收清单

  • 开发库已执行 0002 迁移,Server 能正常启动,测试全部通过
  • 新会话提第一个问题时自动创建会话,会话列表里出现它
  • 同一个会话里的第二个问题能用上第一轮的内容
  • 审批卡片出现在最新一轮,点允许后在同一轮里继续,不新增轮次
  • 只有最新一轮显示“编辑”和“重试”;编辑后这一轮显示新问题和新回答
  • 刷新或重启 Desktop 后,会话列表、聊天记录和审批卡片都能恢复
  • 点击停止后这一轮显示“已停止”和已经写出的部分,后台模型调用同时停止,之后可以重试
  • 停止后直接发“继续”,AI 从断掉的地方接着写,不从头重写
  • 等待审批时编辑这一轮、或者切到别的会话提问,都能正常执行
  • 一个会话执行中可以切到别的会话,并在别的会话里同时提问;停止其中一个不影响另一个

11. 进入下篇

到这里,每一次执行都有了可信的身份、清晰的归属和可以恢复的存档链,agent_run.device_id 和 configurable 里的执行身份也已经就位。下篇在此基础上建立 Server 到 Desktop 的 WSS 通道,新增工具调用的审计表,让模型可以在人工确认和工作区边界内读取用户本地文件,断线、超时、重启都有可对账的结果。