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 先做三件事:
- 把请求体解析成
RunAgentInput(AG-UI 官方的 Pydantic 模型,前端发的是threadId这样的驼峰字段),不合法直接返回 422; - 执行依赖
get_current_user(),得到当前用户; - 执行依赖
get_ag_ui_adapter(),从app.state取出适配器。
之后的调用分三段:prepare() 做检查(7.3~7.5 节),stream() 边执行边推送(7.6 节),官方 Agent 在 stream() 里运行图(7.7 节)。
完整的调用树:
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:执行前的三道检查
调用链路:从请求进来到开始推流。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | api/agent.py · run_agent() | 先 await adapter.prepare(),通过了才返回 SseResponse。prepare() 里抛出的业务异常由 api/errors.py 转成 4xx |
| 2 | runs/context_loader.py · load_agent_run_context() | 借一个连接只读查询:按 run_id 读出执行,校验属于当前用户;普通执行和重跑读历史和本轮问题,审批恢复读审批结果;装成 AgentRunContext |
| 3 | agent/ag_ui_adapter.py · _validate_protocol_input() | 请求里的 threadId、最后一条用户消息、resume[] 必须和上下文一致 |
| 4 | runs/service.py · claim_queued_run() | 一个事务里先锁轮次,再用条件 UPDATE 把执行从 queued 抢成 running,轮次也标为 running;同一个 run_id 第二次来就 409 |
| 5 | api/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() 依次调用了谁。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | agent/ag_ui_adapter.py · stream() | 每个请求 clone() 一份官方 Agent |
| 2 | agent/graph_config.py · build_run_configurable() | 把可信身份放进 config["configurable"],图的节点和工具都能读到(下篇的 Desktop 工具要用) |
| 3 | agent/ag_ui_adapter.py · _build_internal_input() | 构造交给官方 Agent 的输入,见 7.5 节 |
| 4 | agent/ag_ui_adapter.py · _pump_events() | 生产者:在独立的 asyncio 任务里迭代 agent.run(),事件和异常都放进队列 |
| 5 | stream() 的消费循环 | 从队列取事件,按类型处理后 yield 出去,由 SseResponse 编码发给前端 |
| 6 | agent/ag_ui_adapter.py · _persist_finished() | 收到 RUN_FINISHED 时读存档,有中断调 mark_interrupted(),没有调 mark_succeeded(),落库之后才把 RUN_FINISHED 发出去 |
| 7 | agent/ag_ui_adapter.py · _stop_producer() | 断开或出错时对生产者任务调 task.cancel(),等它停下 |
下图是消费循环按事件类型怎么处理:事件从生产者经过队列交给消费者,按类型处理后发给前端;消费者还有三条异常出口。
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 一路到模型和工具。
| 文件 · 方法 | 做什么 |
|---|---|
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 秒一到就在中途被截断,问题接连出现:
- 已经开始的文字消息被截断,收不到
TEXT_MESSAGE_END,图却照常结束,官方集成随即发出RUN_FINISHED; - 协议规定还有文字消息没结束时不能发
RUN_FINISHED,@ag-ui/client直接拒绝整次执行,页面报错Cannot send 'RUN_FINISHED' while text messages are still active; - 适配器只在收到
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 都走这个方法。流程图中的圆角框是出口:走到出口就返回或抛出异常,后面的步骤不再执行。
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
为什么要先锁幂等键,再查。假设用户双击发送,两个请求带着同一个幂等键同时到达:
如果先查再加锁,两个请求可能同时查到“没有”,然后各建一个。先加锁,第二个请求只能等第一个提交之后再查,一定能看到第一个请求的结果。新会话还没有会话行可以锁,所以锁的是幂等键本身,用的是 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 走这个方法:
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 走这个方法,编辑和原样重试的区别只在“问题改了吗”这一步:
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 的一个方法:
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,下面的表是两个迁移都执行后的样子。
| 字段 | 说明 |
|---|---|
id | run_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_id | Resume 执行正在处理的中断 |
idempotency_key | 前端生成的幂等键 |
run_type | normal / resume / retry |
trigger_type | user_input / approval / retry / edit |
status | queued / 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_FOUND | 404 |
| 访问别人的数据 | RUN_FORBIDDEN | 403 |
| 编辑的不是最新一轮 | TURN_NOT_LATEST | 409 |
| 这一轮正在执行 | RUN_IN_PROGRESS | 409 |
| 最新一轮在等待审批,不能提新问题 | TURN_WAITING_APPROVAL | 409 |
| 审批卡片已经处理过、已作废,或者同一个键提交了相反的选择 | INTERRUPT_ALREADY_RESOLVED | 409 |
| 审批的来源执行已经不在等待审批 | INVALID_RESUME_TARGET | 409 |
| 同一个幂等键用在了别的请求上 | IDEMPOTENCY_KEY_REUSED | 409 |
| 同一个执行被启动两次、触发唯一约束 | RUN_CONFLICT | 409 |
| 执行请求和数据库记录对不上 | RUN_INPUT_MISMATCH | 400 |
| 没有预料到的异常(代码 bug、数据库报错等) | INTERNAL_ERROR | 500 |
最后一行由 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_found | 403、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 检验自己是否真的懂了
- 不看代码,能说出各个场景各自调了哪几个接口、数据库里各新增或修改了哪几行。
- 不看代码,能在白纸上画出第 7 章执行接口的调用栈。
- 不借助 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 通道,新增工具调用的审计表,让模型可以在人工确认和工作区边界内读取用户本地文件,断线、超时、重启都有可对账的结果。