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

45 阅读48分钟

1. 我们要做一个什么样的 Agent

1.1 产品形态

DevMind 是一个桌面端的 AI 助手,界面和 Codex、ChatGPT 这类 Agent 产品类似:

  • 左边是会话列表。可以点“新会话”开始新的聊天,也可以点开一个历史会话接着聊。
  • 右边是当前会话的对话。一个会话里可以聊很多轮,每一轮就是一个用户问题 + 一个 AI 回答。
  • AI 回答时可能要调用工具。有些工具需要用户同意,这时 AI 会停下来显示一张审批卡片,用户点“允许”或“拒绝”后,AI 在这一轮里接着往下做,不会新开一轮。
  • 对最新的那一轮不满意,可以编辑问题后重新运行,或者原样重试。历史的轮次不能改,只有每个会话里最新的那一轮可以。

M4 的最终目标是让 AI 能调用用户电脑上的本地工具(读文件等)。本地工具有副作用,所以必须先把“谁在哪个会话的哪一轮、第几次执行时调用了什么”记清楚。上篇做的就是这件事:把会话、轮次、执行这些记录建起来,让上面这些页面操作都有可靠的后端支撑。下篇再在这个基础上接入本地工具。

1.2 用户能做的操作

操作会不会执行 Agent规则
打开应用,看会话列表否只显示自己的会话,最近有新问题的排在前面,最多显示最近 100 个
点开一个历史会话否按轮显示全部问答;刷新页面、重启应用后都能恢复,包括没处理的审批卡片
在新会话里提第一个问题是不需要先“创建会话”,提问时自动创建;会话标题取问题的前 50 个字
在已有会话里再问一个问题是新增一轮;AI 能记住这个会话里前面聊过的内容
在审批卡片上点允许或拒绝是不新增轮次,在这一轮里接着执行
编辑或重试最新一轮是不新增轮次,这一轮换成新问题(重试则问题不变)重新执行;执行中要先停止;等待审批时编辑,审批作废
停止否后台正在进行的模型调用和工具立刻停下,已经写出的部分回答保留,这一轮标为“已停止”;断网和停止的效果一样。只停当前会话,别的会话照常执行
执行中切换到别的会话否随时可以切换、新建会话;原来的会话在后台继续执行,会话栏上显示“执行中”,切回来能接着看
多个会话同时执行是可以,最多同时 4 个会话在执行;同一个会话同时只有一轮在执行。最新一轮停在审批的会话,会话栏上显示“等待审批”
停止或断线后,直接发一个新问题(比如“继续”)是新增一轮;AI 能看到上一轮写到了哪里、是被中断的,并被明确要求从断掉的地方接着写(通常能接上,最终取决于模型)。这个会话执行中或等待审批时不能发新问题

几个容易说混的词,文档里统一这样用:

  • 会话:一个聊天窗口,左边列表里的一项。
  • 轮:一个问题加上 AI 对它的回答。
  • 执行:后台为了回答这一轮跑了一次 Agent。一轮可能执行好几次,比如先停在审批、批准后再执行一次。用户在页面上看不到执行,只看到这一轮最终的回答。
  • 问题:用户在这一轮里输入的内容。编辑改的是问题,不是系统提示词。

2. 前端页面长什么样

2.1 界面

下图是 Renderer 的界面,旁边的标注说明每个元素在后端对应什么(第 3 章细讲):

image.png

2.2 前端代码的结构

文件:apps/devmind-desktop/src/renderer/src/

App.tsx                           页面布局:左边会话栏,右边对话区和输入框
components/
├── ConversationSidebar.tsx       会话栏:“新会话”按钮和历史会话列表,会话旁显示“执行中”“等待审批”
├── TurnCard.tsx                  一轮:问题、回答、状态、审批卡片、编辑和重试按钮
└── Composer.tsx                  输入框:发送、停止
hooks/
├── useConversations.ts           会话列表,以及当前选中哪个会话
├── useTurns.ts                   当前会话的所有轮次,从 Server 读取,按会话缓存
└── useTurnRunner.ts              提问、审批、编辑重跑这三个操作:先准备,再执行;执行状态按会话分开存放,多个会话可以同时执行
agent/
├── devmind-api.ts                所有 DevMind 业务接口的请求封装和类型
└── devmind-agent.ts              每次执行新建一个 AG-UI 官方客户端 HttpAgent,负责执行请求和解析流式事件(为什么每次新建见 8.11)

页面的数据以 Server 为准:会话列表和轮次都从接口读,每次执行结束后重新读一遍,前端不自己拼聊天记录。执行过程中,正在执行的那一轮先显示流式文字,结束后换成 Server 落库的回答。

3. 页面上的东西,在后端是什么

3.1 对照表

页面上后端的表ID说明
会话列表里的一项conversationconversation_id同时是 AG-UI 协议里的 threadId
一轮(一问一答)conversation_turnturn_id用户提一个新问题就新增一轮
问题、回答conversation_messagemessage_id只存用户问题和 AI 最终回答;编辑重跑后旧的问答标为 replaced,页面上就不显示了
看不到:一次执行agent_runrun_id同时是 AG-UI 的 runId,也就是一次流式请求
审批卡片agent_interruptinterrupt_id执行停在审批处时记一条;处理后标为 resolved,编辑作废时标为 cancelled
看不到:存档链LangGraph 的 Checkpoint 表graph_thread_idAgent 执行到哪一步的存档,审批后“接着执行”靠它

它们的层级关系:

用户
└── 会话 conversation                 一个聊天窗口
    └── 轮 turn                        一个问题 + 一个回答(1:N)
        ├── 消息 message               这一轮的问题和回答;编辑重跑后旧的标为 replaced(1:N)
        └── 执行 run                   为这一轮跑了几次 Agent(1:N)
            └── 中断 interrupt         这次执行停下来等审批(0:N)

3.2 为什么“轮”和“执行”要分开

因为用户看到的一轮,后台可能执行了好几次:

这一轮经历了什么页面上看到后台的执行记录
直接回答一问一答1 次:normal,成功
停在审批,用户点了允许一问一答(中间出现过审批卡片)2 次:normal 停在审批(interrupted),resume 接着执行成功
回答不满意,编辑问题重跑一问一答(问题是编辑后的)2 次:normal 成功但被替换,retry 用新问题重跑成功
执行中断网或点了停止,再点重试一问一答2 次:normal 已取消(cancelled),retry 成功
执行中点了停止,然后发“继续”这一轮是问题加半截回答;下一轮是“继续”和接着写的内容这一轮 1 次:normal 已取消;下一轮另有自己的执行

如果只有一张表,要么页面上会冒出好几个回答,要么后台的执行过程会被覆盖掉、没法排查。分成两张表后,页面读 conversation_message,永远是一问一答;审计和排查读 agent_run,每次执行都有记录。

每一轮用 active_run_id 指向当前生效的那次执行,这一轮的状态(等待执行、执行中、等待审批、已完成、已停止、失败)就跟着它走。

三种执行的区别:

执行类型什么时候产生轮存档链上下文从哪来
normal提一个新问题新建新建这个会话最近 20 轮的问答(包括停止和失败的轮次)+ 本轮问题
resume在审批卡片上点允许或拒绝复用复用被中断那次执行的存档(Checkpoint)+ 审批结果
retry编辑或重试最新一轮复用新建和 normal 相同,问题是编辑后的

3.3 存档链:为什么还有一个 graph_thread_id

LangGraph 执行时会不断存档(Checkpoint),存档按 thread_id 串成一条链。审批时 Agent 停在半路,批准后要从这条链的最后一个存档接着跑。

AG-UI 协议里也有一个 threadId,但它表示的是聊天会话。同名不同义,所以 DevMind 把它们拆开:

  • 会话 ID conversation_id:前端和业务表里用的,等于 AG-UI 的 threadId;
  • 存档链 ID graph_thread_id:只在 Server 内部用,交给 LangGraph 时才放进它要求的 thread_id。

一个会话里有很多条存档链:每个新问题、每次重跑都新开一条,只有审批恢复沿用原来那条。新问题用的是新存档链,那它怎么记住上一轮说过的话?答案是上下文从业务表装配(最近 20 轮的问答,包括停止和失败的轮次),而不是从存档里读。

3.4 状态

一轮的状态跟着它当前生效的执行走:

执行的状态轮的状态页面上
queued:准备好了,还没开始执行pending等待执行
runningrunning流式显示回答,可以点停止
interruptedinterrupted显示审批卡片,输入框禁用
succeededcompleted显示回答
failedfailed执行失败,显示已经写出的部分回答;可以重试,或直接发“继续”
cancelledcancelled已停止,显示已经写出的部分回答;可以重试,或直接发“继续”

三个准备方法都会把这一轮设为 pending,包括审批:点了允许或拒绝后,审批卡片消失,这一轮等待第二步开始执行。

4. 前后端怎么通信:写操作都分两步

4.1 接口一览

页面操作接口类型
打开应用,加载会话列表(每个会话带上最新一轮的状态)GET /api/conversations查询
点开一个会话GET /api/turns?conversation_id=查询
提一个新问题(新会话或已有会话)POST /api/turns第一步:准备
编辑或重试最新一轮POST /api/turns/rerun第一步:准备
在审批卡片上点允许或拒绝POST /api/runs/resume第一步:准备
以上三个操作的第二步POST /api/agent第二步:执行(流式返回)
停止前端直接断开第二步的连接无

接口不用路径参数:提交类接口的参数放在 JSON body 里,查询类接口放在 URL 的查询参数里。

4.2 两步分别做什么

会让 Agent 执行的三个操作(提问、审批、编辑重跑)都要调两次接口:

  1. 准备:调对应的准备接口。Server 在一个数据库事务里把这一轮需要的记录建好(新会话时连会话一起建),创建一个排队中(queued)的执行,把 conversation_id、turn_id、run_id、user_message_id 返回给前端。这一步只写数据库,不跑 Agent,几十毫秒就返回。
  2. 执行:前端拿着这些 ID 调 POST /api/agent。Server 按 run_id 从数据库查出这次执行的全部信息,真正运行 Agent,边执行边把文字、工具调用等事件用 SSE 推给前端,结束前把回答写进数据库。

可以把第一步理解为“挂号”,领到一张执行凭证 run_id;第二步是“拿着凭证去看病”。

下面是一次提问的两步:

image.png

4.3 为什么要分成两步

DevMind 是桌面端 Agent,拿同类的 Codex 桌面端和 Claude Code 桌面端来对比。它们的结构和 DevMind 一样:界面是一个进程,Agent 是另一个进程,两者之间要通信。区别在于,它们用的是一条 长连接(本机进程之间的 stdio,或 WebSocket),连接建好之后,所有操作都在这条连接上收发,而不是每次操作发一个 HTTP 请求。

Codex。桌面端和 IDE 插件都通过 app-server 和 Agent 通信,协议是 JSON-RPC:

  • 新会话:先发 thread/start 请求,app-server 创建会话并返回会话对象,会话 ID 由 app-server 生成。
  • 提问:发 turn/start 请求,带上会话 ID 和问题。请求立刻返回这一轮的信息,之后的执行过程作为通知推回来:turn/started、item/started、item/agentMessage/delta(文字增量)、item/completed、turn/completed。
  • 审批:要执行命令或改文件时,app-server 反过来向界面发一个请求(如 item/commandExecution/requestApproval),界面回复允许或拒绝后,同一轮接着执行。
  • 停止:发 turn/interrupt;重新打开历史会话:发 thread/resume。

Claude Code。桌面端启动 Claude Code 的 Agent 进程,通过 stdio 用流式 JSON 通信:

  • 新会话:Agent 进程启动后,在第一条初始化消息里返回由 Agent 端生成的会话 ID。
  • 提问:往进程写一条用户消息,回答以流式消息推回来。
  • 审批:要用工具时,Agent 向界面发一个权限请求,界面回复后同一轮接着执行。
  • 恢复历史会话:按会话 ID 重新启动 Agent 进程并恢复。

说明:Codex 的描述来自它开源的 app-server 协议(openai/codex 仓库的 codex-rs/app-server-protocol),Claude Code 的描述来自 Claude Agent SDK 的公开文档,都可能随版本变化。

把它们和 DevMind 放在一起看:

新会话的 ID提一个问题执行过程怎么推回来审批
Codexthread/start 返回,app-server 生成turn/start,立刻返回这一轮同一条连接上的通知app-server 向界面发请求,同一轮继续
Claude Code初始化消息返回,Agent 端生成往连接写一条用户消息同一条连接上的流式消息Agent 向界面发请求,同一轮继续
DevMind第一步准备接口返回,Server 生成第一步准备接口,立刻返回 run_id第二步 POST /api/agent 的 SSE这次执行停下并落库;审批后准备一个恢复执行,同一轮继续

可以看到,这些桌面端 Agent 本质上也是“先拿到 ID,再收执行过程”:会话 ID 由 Agent 端生成,提问的请求很快返回,执行过程在另一个通道里推回来。只是有了长连接,“收执行过程”不需要再发一个请求,所以看起来像一步。DevMind 用的是 HTTP,界面和 Server 之间没有常驻的连接,只能把“收执行过程”做成第二个请求(SSE)。注意,下篇的 WSS 是 Server 调用 Desktop 本地工具的通道,不是用来推送执行过程的;执行过程仍然走 /api/agent 的 SSE。

审批的做法也不一样。Codex 和 Claude Code 的 Agent 进程一直活着,暂停的这一轮就留在进程内存里,等界面回复后直接继续。DevMind 的 Server 可能重启,所以暂停时要把这次执行记为 interrupted 并落库,审批后再准备一个恢复执行,从存档接着跑;对用户来说仍然是同一轮。

DevMind 选两步的理由:

  • 新会话的 ID 由 Server 生成。AG-UI 的 RunAgentInput 里 threadId 是必填的,前端发请求时就得带上。一步走时,新会话的第一问还没有 ID,前端组不出合法的 AG-UI 请求,要么退回前端生成 ID,要么另写一个非标准的执行接口。Codex 也是这样:新会话先 thread/start 拿到 app-server 生成的 ID,再用它 turn/start。两步走时,第一步由 Server 建会话并返回 conversation_id,第二步再把它当作 threadId 发出去。这是决定性的一条。
  • 协议边界清楚。第一步是 DevMind 自己的业务接口,处理幂等键、编辑后的问题、审批结果;第二步是标准的 AG-UI 执行接口,直接交给 ag-ui-langgraph,不往 forwardedProps 里塞业务字段,也不在适配器里写业务校验。
  • 执行是 Server 上的资源。run_id 由第一步创建并落库,第二步只能“认领”已经存在的执行,而且只能认领一次,同一个请求发两次也只执行一次(M3 里 runId 是前端随机生成的,Server 没法判断它合不合法)。提问、审批恢复、编辑重跑、停止后继续,都是“先准备一个 run,再执行”,前端只有一个 prepareThenExecute。下篇 Server 调用本地工具时,也靠这个 run_id 说明是哪一次执行发起的调用。
  • 业务校验集中在第一步。会话不存在、没有权限、重复提交,在第一步返回 403、404、409。第二步开始推流后,出错只能通过流里的错误事件告诉前端。
  • 问题先落库。第二步断网或失败,这一轮已经在库里,刷新后还能看到并重试。

两步走的代价:

  • 多一次请求往返。桌面端连本机 Server,只多几毫秒,和模型几秒的响应相比可以忽略。
  • 可能出现“准备好了但没执行”的执行记录。比如第一步成功后前端崩了,这个执行会一直是 queued。下一次提问时,Server 会作废这张凭证(执行记为 cancelled,错误码 ABANDONED,见 6.8 场景 5 的表格);编辑或重试这一轮时也会作废它,错误码是 REPLACED_BY_RERUN(见 6.7 场景 4)。

什么时候可以只用一个请求:界面和 Agent 之间有常驻的长连接,执行过程可以直接从这条连接推回来(像 Codex 和 Claude Code);或者会话 ID 可以由前端生成,一个请求就能组好。DevMind 两条都不满足。

4.4 幂等键:为什么由前端生成

准备接口都带一个幂等键 idempotency_key,作用是让 Server 认出“这是同一个请求的重发”:请求因为网络超时被重发,只会创建一次。

它必须由前端生成:前端每点一次按钮就生成一个(randomUUID()),这次操作的重发都复用它。假设第一次请求的响应在网络上丢了,前端重发时带的还是同一个键,Server 一查就知道是重发,直接返回第一次的结果。如果幂等键由 Server 生成,第一次的响应丢了,前端手里就没有这个键,重发的请求和新请求没法区分。

要分清幂等键防的是什么:它防的是同一个请求被重发,不是用户点了两次。前端每次点击都生成一个新的键,两次点击就是两个不同的请求。防双击靠前端:从第一步开始到第二步结束,按钮全部禁用,还用一个 ref 同步拦截来不及重新渲染的第二次点击(见 8.11)。目前前端没有自动重发,幂等键是为网络层重发和以后加的重试准备的。

审批的幂等键是 approval: 加中断 ID,同一张审批卡片重复提交得到的是同一次执行。如果用同一个键提交了相反的选择(先允许、再拒绝),返回 409 INTERRUPT_ALREADY_RESOLVED,不会悄悄返回第一次的结果。

5. 后端分成哪些模块

5.1 架构图

蓝线是第一步准备请求,只经过左半边;橙线是第二步执行请求,经过中间的适配器,按 a、b、c、d 的顺序同时用到左右两边:

image.png

  • 左边管记账:谁、哪个会话、哪一轮、执行了几次、结果如何,存在五张业务表里。这一半是 M4 新写的。
  • 右边管干活:调用模型、执行工具、每走一步存档。这一半是 M3 的 LangGraph,只改了模型超时一处(见 7.9 节)。
  • 中间的 适配器 是桥:进去之前把前端的输入换成数据库里的可信数据,出来之后把结果写回业务表。

5.2 模块和它们存在的理由

模块位置负责什么为什么要单独成一块
apiapi/HTTP 层:路由、请求和响应模型、依赖注入、异常到状态码的映射业务代码不认识 HTTP,以后 MCP 入口、后台任务也能直接调 Service
runsruns/业务模块:会话、轮次、消息、执行、中断的数据和规则所有“能不能做、怎么记”的规则集中在这里,页面操作对应到它的一个个方法
agentagent/执行引擎:M3 的 LangGraph 图,以及适配器执行引擎不知道业务表的存在,换引擎不影响业务记录;适配器是两者之间唯一的连接点
corecore/配置、ID 生成、启动时检查迁移所有模块都要用的基础设施
装配main.py启动时创建所有单例,挂到 app.state 上Python 没有 Spring 那样的自动注入,需要一个地方手动把对象装起来
迁移migrations/五张业务表的建表脚本,按编号依次执行表结构有版本记录,已有的数据库能按顺序升级

runs 模块内部再分层,每层只做一件事:

层文件做什么不做什么
领域模型models.py、errors.py状态枚举、数据对象、业务异常不含逻辑
Repositoryrepository.py一个方法一条 SQL,把结果行转成对象不判断能不能做
Serviceservice.py生成 ID、校验所有权、幂等、开事务、决定调用哪些 SQL不写 SQL,不认识 HTTP 状态码
可信上下文context.py、context_loader.py执行前按 run_id 从数据库装配这次执行需要的一切不写数据库

依赖只能从上往下:api 调 runs 和适配器;适配器调 runs;runs 不 import FastAPI,也不 import agent。

这种“先按业务模块分,模块内再分层”的方式,和 Java 里 controller、service、dao 各一个包的按层分包不同。好处是以后加 Knowledge、Permission 等模块时,各自新建一个包,互不干扰。

5.3 文件地图

代码仓库地址: gitee.com/panghuzhuai…

精读 是要逐行读懂、能自己改的;通读 是知道每个函数做什么;略读 是知道它存在。第二列标出文件来自哪里:上篇 是本篇新写的,上篇改 是早期里程碑写的、本篇改过,M1~M3 是早期里程碑写的、本篇没动。M1、M2 的手写 Agent 循环、它的 AG-UI 适配层和 Mock 事件流,已经被 M3 的 LangGraph 和本篇的适配器取代,已从项目里删除。

apps/devmind-server/
├── migrations/
│   ├── 0001_add_agent_runtime_tables.sql    上篇     通读   五张业务表和约束
│   └── 0002_turn_rerun_and_cleanup.sql      上篇     通读   编辑重跑、按用户判重幂等、删掉预留字段
├── src/devmind_server/
│   ├── main.py                              上篇改   通读   启动装配
│   ├── core/
│   │   ├── config.py                        上篇改   略读   配置(M1 建,本篇加了开发模式、跨域来源等)
│   │   ├── ids.py                           上篇     略读   UUIDv7
│   │   └── migrations.py                    上篇     略读   启动时检查迁移是否都执行过
│   ├── api/
│   │   ├── conversations.py                 上篇     通读   会话列表、按轮查询、提新问题、编辑重跑
│   │   ├── runs.py                          上篇     通读   审批
│   │   ├── agent.py                         上篇改   通读   执行接口
│   │   ├── schemas/runs.py                  上篇     略读   请求和响应模型
│   │   ├── dependencies.py                  上篇     通读   当前用户、Service、适配器的依赖注入
│   │   ├── errors.py                        上篇     略读   领域异常 → HTTP 状态码;未处理的异常 → 带跨域头的 500
│   │   ├── router.py                        上篇改   略读   路由汇总
│   │   ├── health.py                        M1       略读   GET /health 健康检查
│   │   └── desktop.py                       下篇占位 略读   Ticket 与 WSS 入口,还没有接进路由
│   ├── runs/
│   │   ├── service.py                       上篇     精读   三个准备方法、执行期状态流转
│   │   ├── repository.py                    上篇     通读   全部 SQL
│   │   ├── context_loader.py                上篇     通读   执行前装配可信上下文
│   │   ├── context.py                       上篇     略读   上下文的数据结构
│   │   ├── models.py                        上篇     略读   状态枚举、数据对象
│   │   └── errors.py                        上篇     略读   业务异常
│   └── agent/
│       ├── ag_ui_adapter.py                 上篇     精读   适配器:可信输入、事件转发、落库
│       ├── graph_config.py                  上篇     略读   LangGraph 的 config
│       ├── langgraph_runtime/
│       │   ├── graph.py                     上篇改   略读   M3 的状态图:model 调模型 → 需要确认的工具先进 approval(interrupt 暂停)
│       │   │                                                 → tools 执行工具 → 回到 model;本篇只改了模型超时(7.9 节)
│       │   ├── state.py                     M3       略读   图的状态:messages、tool_approved、model_rounds、stop_reason 等
│       │   └── factory.py                   M3       略读   组装图:注册三个工具、创建模型、接上 PostgreSQL Checkpointer
│       ├── model_gateway.py                 M1       略读   模型访问边界:按 .env 创建模型、绑定工具、统一流式调用
│       ├── tool_registry.py                 M1       略读   工具注册中心:按名字查找、校验参数、执行、包成 ToolMessage
│       ├── prompts.py                       M1       略读   系统提示词
│       └── tools/                           M1       略读   三个演示工具:add、get_current_time、demo_text(需要确认)
├── tests/
│   ├── test_m4a_runs.py                     上篇     通读   按场景组织的测试
│   └── scripted_model.py                    上篇     略读   按脚本返回的假模型
├── scripts/check_redis.py                   M3       略读   检查 Redis 能否连通(Redis 从 v0.2 开始用)
└── .env.example、pyproject.toml             上篇改   略读   配置模板、依赖

apps/devmind-desktop/
├── src/main/index.ts                        M2       略读   Electron 主进程:创建窗口(下篇的 WSS 客户端会放在这里)
├── src/preload/index.ts                     M2       略读   预加载脚本:通过 contextBridge 暴露给页面的能力
├── src/renderer/index.html                  M2       略读   页面入口,CSP 白名单里配置 Agent Server 地址
├── src/renderer/src/
│   ├── main.tsx                             M2       略读   React 挂载入口
│   ├── App.tsx                              上篇改   通读   页面布局,当前显示哪个会话(M2 建,本篇重写)
│   ├── hooks/
│   │   ├── useTurnRunner.ts                 上篇     精读   先准备、再执行;执行状态按会话存放
│   │   ├── useTurns.ts                      上篇     通读   当前会话的轮次,按会话缓存
│   │   └── useConversations.ts              上篇     通读   会话列表、当前选中的会话
│   ├── components/                          上篇     通读   会话栏、一轮、输入框
│   └── agent/
│       ├── devmind-api.ts                   上篇     通读   业务接口的请求封装和类型
│       └── devmind-agent.ts                 上篇改   通读   每次执行新建一个 HttpAgent
└── .env.example                             上篇改   略读   前端配置:Server 地址、CSP 白名单、开发用户

apps/devmind-compose.yml                     M3       略读   本地开发用的 PostgreSQL(带 pgvector)和 Redis

一次执行经过的代码:本篇的适配器 → 官方 LangGraphAgent → M3 的 langgraph_runtime/graph.py → M1 的 model_gateway.py 调模型、tool_registry.py 执行 tools/ 里的工具。

5.4 用到的库

库用来做什么Java 里的类比
FastAPI路由、依赖注入、统一异常处理、启动和关闭钩子Spring MVC
Pydantic、pydantic-settings请求体校验(失败自动返回 422)、读取 .env 配置DTO + Bean Validation、@ConfigurationProperties
psycopg 3、psycopg_pool执行 SQL、事务、连接池JDBC、HikariCP
LangGraph、langgraph-checkpoint-postgresAgent 状态图,存档存进 PostgreSQL无
ag-ui-protocol、ag-ui-langgraphAG-UI 协议的数据模型;把 LangGraph 事件翻译成 AG-UI 事件的官方集成无
anyio客户端断开后仍能完成收尾(CancelScope)无
uuid-utils生成按时间有序的 UUIDv7无
@ag-ui/client前端的 AG-UI 客户端 HttpAgent:发请求、解析 SSE、按事件类型回调无

本篇直接写 SQL,没有用 ORM。执行的状态流转依赖条件 UPDATE、行锁和部分唯一索引,直接写 SQL 最清楚每一步在数据库里做了什么,也能和 LangGraph 的存档共用一个连接池。

6. 场景:点一下按钮,代码从哪个文件走到哪个文件

这一章按用户实际的操作顺序,把代码串起来走一遍。例子是同一个会话:先问“我叫小明”,再问“我叫什么”,再让 AI 读取示例文本(需要审批),然后编辑这个问题,最后是回答到一半被停止、用户发“继续”。

每个场景按同样的顺序写:

  1. 调用链路图:从点击按钮开始,经过哪个文件的哪个方法,再到哪个文件的哪个方法。每个框写成“文件 · 方法:做什么”,箭头表示调用,箭头上写的是请求或者传回来的东西。
  2. 调用明细表:按调用顺序,一行一个方法:在哪个文件、做了什么。
  3. Service 里的调用树:第一步在一个事务里依次调用了 Repository 的哪些方法,缩进表示“被上一层调用”。
  4. 数据库变化和要注意的地方。

路径的写法:后端文件省略前缀 apps/devmind-server/src/devmind_server/,前端文件省略前缀 apps/devmind-desktop/src/renderer/src/;图里的 repository 指 runs/repository.py 的 RunRepository。所有写操作都在 async with self._transaction() as conn: 里:进入时借连接并开始事务,离开时提交;中间任何一步抛异常,整个事务回滚,数据库里什么都不会留下。

第二步 POST /api/agent 在每个场景里走的是同一套代码。这一章只写每个场景自己特有的部分,完整的拆解在第 7 章。

6.1 开始之前:Server 启动时装好了哪些对象

接口函数里用到的 RunService、适配器、LangGraph 的图,都不是请求来了才创建的。Server 启动时,main.py 的 lifespan() 按依赖顺序把它们各建一次,挂到 app.state 上;每个请求进来时,api/dependencies.py 的依赖函数再把它们取出来,作为参数传给接口函数。后面说的“注入”,指的就是这一步。

image.png

文件 · 对象做什么谁用它
agent/tools/*.py三个工具函数,用 @tool 声明名字、参数和说明ToolRegistry、ModelGateway
agent/tool_registry.py · ToolRegistry按名字保存工具。execute() 执行一次工具调用:找不到工具、参数不对、超过 10 秒、抛异常,都转成一条 ok: false 的工具结果交回模型,不会让整次执行失败图的 tools 节点
agent/model_gateway.py · create_chat_model_from_settings()按 .env 里的地址、模型名、密钥创建 ChatOpenAIcreate_langgraph_runtime()
agent/model_gateway.py · ModelGatewaybind_tools 把工具说明告诉模型,并禁止一次调用多个工具;astream() 逐段返回模型输出图的 model 节点
agent/langgraph_runtime/graph.py · build_agent_graph()把模型网关和工具注册表交给节点函数,注册 5 个节点和它们之间的边,用存档器编译成可执行的图LangGraphAgent、适配器
ag_ui_langgraph · LangGraphAgent官方集成:运行图,把图的事件翻译成 AG-UI 事件适配器每次执行 clone() 一份
runs/repository.py · RunRepository一个方法对应一条(或一组)SQL,不做业务判断RunService、context_loader
runs/service.py · RunService事务、加锁、幂等、状态能不能流转接口函数、适配器
agent/ag_ui_adapter.py · DevMindAgUiAdapter执行接口的核心,见第 7 章api/agent.py
api/dependencies.pyget_current_user():开发期从 X-Dev-User 请求头取用户;get_run_service()、get_ag_ui_adapter():从 app.state 取对象,启动中或关闭中返回 503所有接口函数

图里有哪些节点、节点之间怎么走,见 7.7 节;lifespan() 的代码见 8.10 节。

6.2 全景图:每个接口进来之后,在哪些文件里做了什么

6.1 是前置条件。下面这张图把 Server 的 7 个接口画在一起:

最上面是启动时装好的对象,接着是每个请求都要经过的公共路径,然后一行一个接口。每一行左边是接口路径和接口函数,右边上排是大流程,按 ①②③ 的顺序执行;标题带 ↓ 的格子,用粗箭头展开成下面一排小流程。格子的颜色表示代码在哪一层;格子里蓝字是调用的方法,橙字是加锁,红字是出错直接返回。

image.png

图比较大,可以点开画板放大看。读的时候抓住这几点:

  • 两个 只读 接口:GET /api/conversations 和 GET /api/turns 只借一个连接查数据,不开事务,也不加锁。
  • 三个准备接口:POST /api/turns、POST /api/turns/rerun、POST /api/runs/resume 都包在 _transaction() 里,开头都是“锁幂等键、查幂等”,结尾都是写一个 queued 的执行,把 run_id 返回给前端。
  • 一个执行接口:POST /api/agent 拿着这个 run_id 真正运行图,边跑边推事件,结束时按四种情况落库。
  • 加锁顺序:每一行里的橙字,从左往右都是“幂等键 → 会话 → 轮次 → 执行 → 中断”,用不到的可以跳过,但不会倒过来。

后面的场景,就是在这张图里挑几行走一遍:场景 0 是两个 GET;场景 1、2 是 POST /api/turns 加 /api/agent;场景 3 是 /api/runs/resume 加 /api/agent;场景 4 是 /api/turns/rerun 加 /api/agent;场景 5 是 /api/agent 里“用户点停止”那个结束分支,再加一次 POST /api/turns。

6.3 场景 0:打开应用,点开一个历史会话

只读,不执行 Agent。

调用链路:打开应用,点开一个会话。

image.png

顺序文件 · 方法做什么
1hooks/useConversations.ts · useEffect页面挂载时调 listConversations()。选中的会话 ID 存在 localStorage,重启应用后自动打开上次的会话
2agent/devmind-api.ts · listConversations() → requestJson()带上开发期身份头发请求;非 2xx 抛 ApiError,连不上 Server 抛“连接 Server 失败”
3api/conversations.py · list_conversations()FastAPI 先执行依赖 get_current_user()、get_run_service(),再调 Service
4runs/service.py · RunService.list_conversations()只读,借一个连接、不开事务。里面调 repository.list_conversations():当前用户的会话按 updated_at 倒序,带上每个会话最新一轮的状态 latest_turn_status
5hooks/useConversations.ts · select(id)点会话时调用:写进 localStorage,更新 selectedId
6hooks/useTurns.ts · useEffectselectedId 变了就调 listTurns(),结果按会话 ID 缓存。返回 404 或 403(会话已经不存在)时调 onMissing,页面回到“新会话”,而不是一直显示报错
7api/conversations.py · list_turns()conversation_id 从查询参数取,缺了返回 422
8runs/service.py · RunService.list_turns()里面依次调 _check_conversation_owner()(→ repository.get_conversation())校验归属、repository.list_turn_views() 取每轮的视图,最后补 is_latest、can_rerun
9App.tsx按轮渲染 TurnCard。会话列表或轮次加载失败(比如 Server 没启动)时,底部显示错误和“重新加载”按钮,Server 启动后点它就能恢复

后端调用树:

GET /api/conversations
└─ api/conversations.py 的 list_conversations()
    └─ RunService.list_conversations()
        └─ repository.list_conversations()          当前用户的会话,按 updated_at 倒序

GET /api/turns?conversation_id=c1
└─ api/conversations.py 的 list_turns()
    └─ RunService.list_turns()
        ├─ _check_conversation_owner()              会话不存在 → 404,不是自己的 → 403
        ├─ repository.list_turn_views()             一条 SQL:每轮的问题、当前回答、失败原因、待审批的中断
        └─ 在 Python 里补两个字段:
              is_latest  = 是不是最后一轮
              can_rerun  = 是最后一轮,而且不在执行中

返回的每一轮大致是这样:

{
  "id": "t3",
  "sequence_no": 3,
  "status": "interrupted",
  "question": "读取示例文本",
  "answer": null,
  "error_code": null,
  "pending_approval": {
    "interrupt_id": "i1",
    "reason": "tool_approval",
    "message": "是否允许执行工具 demo_text?"
  },
  "is_latest": true,
  "can_rerun": true
}

前端不需要自己判断“哪一轮能编辑”“审批卡片放在哪”,直接按这些字段渲染。

6.4 场景 1:新会话,提第一个问题

用户点“新会话”,输入“我叫小明”,点发送。

点“新会话”只是把选中的会话设为 null、换一个新的草稿键,Server 上还什么都没有。

调用链路:新会话里提第一个问题。

image.png

顺序文件 · 方法做什么
1components/Composer.tsx点发送,调 onSend(content)
2App.tsx调 runner.ask(currentKey, selectedId, content)。新会话时 selectedId 是 null,currentKey 是草稿键 draft:…
3hooks/useTurnRunner.ts · ask() → prepareThenExecute()这个会话上一个操作还没结束就忽略这次点击;同时执行的会话已满 4 个就提示;状态设为 PREPARING
4agent/devmind-api.ts · createTurn()用新生成的幂等键 POST /api/turns,body 里没有 conversation_id
5api/conversations.py · create_turn()CreateTurnRequest 校验请求体(不通过直接 422),注入用户和 RunService,调 prepare_new_turn()
6runs/service.py · RunService.prepare_new_turn()在一个事务里建会话、轮、问题、执行,见下面的调用树
7hooks/useTurnRunner.ts · moveKey()执行状态从草稿键挪到真正的会话 ID c1 下
8App.tsx · onPrepared()用户还停在这个新会话页面,就切到 c1,这一轮先以“等待执行”显示出来;已经切走了就不动。然后刷新会话列表
9hooks/useTurnRunner.ts · execute()createDevmindAgent("c1") 新建一个 HttpAgent(threadId 是 c1),唯一一条消息的 ID 用返回的 user_message_id,调 runAgent({ runId: "r1" }) 发起第二步;事件回调把回答一段段追加到 streamingText
10App.tsx · onSettled() → hooks/useTurns.ts · reload()执行结束后重新读轮次和会话列表,显示落库后的回答

后端第一步的调用树:

POST /api/turns   body: {"content": "我叫小明", "idempotency_key": "k1"}
│
├─ FastAPI:用 CreateTurnRequest 校验请求体(不通过直接 422);
│           执行依赖 get_current_user() → 当前用户,get_run_service() → RunService
│
└─ api/conversations.py 的 create_turn()
    └─ RunService.prepare_new_turn()
        └─ async with self._transaction() as conn:
            ├─ repository.lock_idempotency_key()      锁“用户 + k1”,同一个键的并发请求在这里排队
            ├─ repository.find_run_by_idempotency()   k1 用过吗?用过 → 直接返回上次的结果
            ├─ 没带 conversation_id:
            │   └─ repository.create_conversation()   新建会话,标题“我叫小明”
            ├─ repository.next_turn_sequence()        会话的轮次序号 +1 → 1
            ├─ repository.insert_turn()               新建第 1 轮
            ├─ repository.insert_message()            写入问题
            ├─ repository.insert_run()                新建执行:normal、queued、新的 graph_thread_id
            └─ repository.set_turn_entry()            回填这一轮的问题 ID 和当前执行 ID
        ← 返回 {"conversation_id": "c1", "turn_id": "t1", "run_id": "r1", "user_message_id": "m1", ...}

数据库变化(第一步之后):

表变化
conversation新增 c1:标题“我叫小明”,last_turn_seq = 1
conversation_turn新增 t1:第 1 轮,pending,问题是 m1,当前执行是 r1
conversation_message新增 m1:user,“我叫小明”
agent_run新增 r1:normal,queued,存档链 g1

后端第二步:执行接口的内部流程每个场景都一样,统一在第 7 章讲。这里只看结果:r1 变成 running 再变成 succeeded,新增一条 assistant 消息“你好,小明”,t1 变成 completed。

要注意:新会话的 ID 是 Server 生成的,前端只能从第一步的响应里拿到它。如果第一步的响应丢了,前端重发同一个请求(同一个幂等键、同样不带会话 ID),Server 按幂等键查到第一次建好的执行,返回同一个会话,不会建出两个会话(见 8.3 节)。

6.5 场景 2:已有会话,再问一轮

在会话 c1 里输入“我叫什么”。

前端走的路和场景 1 一样,调 runner.ask("c1", "c1", "我叫什么"),唯一的区别是 body 里带了 conversation_id。后端第一步把“新建会话”换成了“锁住并校验会话”;第二步多了一件事:从业务表读出前几轮的问答。

调用链路(和场景 1 相同的部分合成了一个框):

image.png

文件 · 方法和场景 1 的区别
agent/devmind-api.ts · createTurn()body 带 conversation_id: "c1"
runs/service.py · prepare_new_turn()不新建会话,而是调 repository.lock_conversation() 锁住会话行,并校验存在、属于当前用户;再调 _check_latest_turn_finished():同一个会话同时只能有一轮在执行,最新一轮还在执行或等待审批就返回 409,见场景 5
runs/context_loader.py · load_agent_run_context()第二步里用 list_history_turns() 读出前几轮问答,_history_messages() 转成消息,和本轮问题一起交给模型

后端第一步的调用树:

POST /api/turns   body: {"conversation_id": "c1", "content": "我叫什么", "idempotency_key": "k2"}
└─ RunService.prepare_new_turn()
    ├─ 锁幂等键、查幂等键(同场景 1)
    ├─ repository.lock_conversation()                 SELECT ... FOR NO KEY UPDATE 锁住会话行
    │   └─ 会话不存在 → 404;不是自己的 → 403
    ├─ _check_latest_turn_finished()                  最新一轮还在执行 → 409;等待审批 → 409(见场景 5)
    ├─ repository.next_turn_sequence()                → 2
    └─ 新建第 2 轮、问题、执行 r2(新的存档链 g2)、回填

数据库变化:新增 t2(第 2 轮)、m3(问题)、r2(normal,存档链 g2)。会话的 last_turn_seq 变成 2,updated_at 更新,所以它会排到会话列表最上面。

第二步里值得注意的一点:r2 用的是新存档链 g2,里面什么都没有。AI 之所以知道“你叫小明”,是因为执行前 context_loader 从业务表里读出了这个会话最近 20 轮的问答,和本轮问题一起交给模型。

6.6 场景 3:停在审批,用户点允许

在 c1 里输入“读取示例文本”。第二步执行时,模型决定调用需要审批的 demo_text 工具,Agent 停在审批节点:r3 变成 interrupted,agent_interrupt 新增一条 open 的中断 i1,t3 变成 interrupted。前端收到 RUN_FINISHED 后刷新轮次,最新一轮带着 pending_approval,显示审批卡片,输入框禁用。

用户点“允许”。

调用链路:从停在审批开始,到点允许后工具执行完。

image.png

顺序文件 · 方法做什么
1agent/langgraph_runtime/graph.py · request_approval()r3 执行时模型要调 demo_text,route_after_model() 把它送到审批节点,interrupt() 暂停并存档
2agent/ag_ui_adapter.py · _persist_finished()收到 RUN_FINISHED 后读存档,发现有中断,调 service.mark_interrupted()
3runs/service.py · mark_interrupted()insert_interrupt() 写入 i1(open),r3 和 t3 标为 interrupted
4App.tsx · onSettled() → hooks/useTurns.ts · reload()最新一轮带着 pending_approval,TurnCard 显示审批卡片,输入框禁用
5components/TurnCard.tsx点允许,调 onApprove("i1", true)
6hooks/useTurnRunner.ts · approve(currentKey, "i1", true)第一步用 resumeRun;第二步不带用户消息,带 resume: [{interruptId: "i1", status: "resolved", payload: {approved: true}}]
7agent/devmind-api.ts · resumeRun()幂等键固定为 approval:i1,同一张卡片点几次都是同一个键
8api/runs.py · resume_run()ResumeRequest 校验请求体,调 prepare_resume()
9runs/service.py · prepare_resume()见下面的调用树
10runs/context_loader.py · load_agent_run_context()r4 是 resume 执行:对话已经在存档链 g3 里,不读历史;get_interrupt() 读出 i1 的审批结果
11agent/ag_ui_adapter.py · _build_internal_input()消息为空,resume 按数据库里的审批结果重新生成,前端传来的 payload 不采信
12graph.py · request_approval() → route_after_approval() → execute_tools()从存档恢复,interrupt() 返回 {"approved": true},去执行工具,再回到模型生成回答

后端第一步的调用树:

POST /api/runs/resume   body: {"interrupt_id": "i1", "approved": true, "idempotency_key": "approval:i1"}
└─ api/runs.py 的 resume_run()
    └─ RunService.prepare_resume()
        └─ async with self._transaction() as conn:
            ├─ 锁幂等键、查幂等键                       重复点击 → 返回同一个 Resume 执行;换成相反的选择 → 409
            ├─ repository.get_interrupt(i1)            不存在 → 404
            ├─ repository.get_run(r3)                  由中断反查被中断的执行;不是自己的 → 403
            ├─ repository.lock_turn(t3)                按“轮次 → 执行 → 中断”的统一顺序加锁(见 8.3)
            ├─ repository.lock_run(r3)
            ├─ repository.lock_interrupt(i1)           锁住后再检查:已经处理过或已作废 → 409
            ├─ repository.next_turn_run_sequence()     这一轮的第 2 次执行
            ├─ repository.insert_run()                 新建 r4:resume、queued,存档链沿用 r3 的 g3
            ├─ repository.resolve_interrupt()          i1 → resolved,保存 {"approved": true}
            ├─ repository.set_turn_active_run()        t3 的当前执行切到 r4
            └─ repository.update_turn_status_for_run() t3 → pending,审批卡片消失

数据库变化:

表变化
agent_run新增 r4:resume,queued,存档链 g3(和 r3 相同),parent_run_id = r3
agent_interrupti1:open → resolved,resolution_payload = {"approved": true}
conversation_turnt3 的当前执行:r3 → r4;状态 interrupted → pending
conversation_turn、conversation_message不新增轮次,不新增问题

第二步里值得注意的两点:

  • 存档链沿用 g3,LangGraph 从 r3 停下的那个存档接着执行,所以不会重新问一遍模型要不要调工具,而是直接执行工具。
  • 审批结果以数据库为准:适配器从 i1 的 resolution_payload 读出 {"approved": true} 交给 LangGraph,前端请求里的 resume 是为了符合 AG-UI 协议:Server 只校验其中的 interruptId 必须是 i1(对不上返回 400),payload 里的审批结果不采信。

如果点的是“拒绝”,流程完全一样,只是保存的是 {"approved": false},LangGraph 不执行工具,而是把“用户拒绝”告诉模型,由模型给出回答。

6.7 场景 4:编辑最新一轮后重新运行

假设第 3 轮还停在审批(还没点允许),用户觉得不需要读文件了,点第 3 轮的“编辑”,把问题改成“用一句话介绍你自己”,点“保存并重新运行”。(编号接着场景 3 往下排:r4 是场景 3 里点允许才会产生的执行,本场景没有点允许,所以新执行叫 r5,中间的 r4 不存在。)

调用链路:编辑最新一轮,保存并重新运行。

image.png

顺序文件 · 方法做什么
1components/TurnCard.tsx只有 can_rerun 为真(最新一轮、不在执行中)时才能编辑和重试。保存时调 onRerun(turn, content);点“重试”时不传 content,问题不变,其余完全一样
2hooks/useTurnRunner.ts · rerun(currentKey, turn, content)第一步用 rerunTurn;第二步的问题是新内容,重试时是原问题
3agent/devmind-api.ts · rerunTurn()POST /api/turns/rerun,body 是 turn_id、content 和新生成的幂等键
4api/conversations.py · rerun_turn()RerunTurnRequest 校验请求体,调 prepare_rerun()
5runs/service.py · prepare_rerun()见下面的调用树
6hooks/useTurnRunner.ts · execute()唯一的消息是新问题,ID 用返回的 user_message_id(是一个新的 ID)

这一步的第二步不带审批结果,而上一次执行停在了审批。前端必须每次执行都新建一个 HttpAgent,否则客户端库会因为“上次的中断没有回应”在发请求前直接报错,见 8.11 节。

后端第一步的调用树:

POST /api/turns/rerun   body: {"turn_id": "t3", "content": "用一句话介绍你自己", "idempotency_key": "k5"}
└─ api/conversations.py 的 rerun_turn()
    └─ RunService.prepare_rerun()
        └─ async with self._transaction() as conn:
            ├─ 锁幂等键、查幂等键
            ├─ repository.get_turn(t3)                 不存在 → 404
            ├─ repository.lock_conversation(c1)        不是自己的 → 403
            ├─ repository.lock_turn(t3)
            │   └─ t3 的序号不等于会话的 last_turn_seq → 409 TURN_NOT_LATEST(历史轮次不能改)
            ├─ repository.lock_run(r3)                 这一轮当前的执行:
            │   ├─ running     → 409 RUN_IN_PROGRESS(先点停止)
            │   ├─ queued      → cancel_queued_run(),作废这张没用上的凭证
            │   └─ interrupted → cancel_open_interrupts(),i1 标为 cancelled(本例走这里)
            ├─ repository.get_turn_user_message()      原来的问题
            ├─ repository.replace_turn_messages()      旧问题和旧回答标为 replaced(重试时只替换回答)
            ├─ repository.insert_message()             写入新问题 m6
            ├─ repository.set_turn_user_message()      t3 的问题换成 m6
            ├─ (如果改的是第 1 轮)update_conversation_title()
            ├─ repository.next_turn_run_sequence()     这一轮的第 2 次执行
            ├─ repository.insert_run()                 新建 r5:retry、trigger_type = edit、新的存档链 g5
            ├─ repository.set_turn_active_run()        t3 的当前执行切到 r5
            └─ repository.update_turn_status_for_run() t3 回到 pending

数据库变化:

表变化
agent_interrupti1:open → cancelled,之后再点这张审批卡片会得到 409
conversation_message旧问题“读取示例文本”:completed → replaced;新增 m6“用一句话介绍你自己”
agent_run新增 r5:retry,trigger_type = edit,parent_run_id = r3,存档链 g5(新的)
conversation_turnt3:问题换成 m6,当前执行换成 r5,状态回到 pending;不新增轮次

第二步:和普通提问一样执行。上下文是前两轮的问答加上新问题;旧问题和旧回答已经是 replaced,不会进入上下文,页面上也不再显示。

为什么旧问答是“标记”而不是删除:页面和模型只读 completed 的消息,旧的自然就看不到了;但数据库里还留着,排查时能看到这一轮被改过几次、每次执行的结果是什么。

6.8 场景 5:回答到一半停止或断线,然后发“继续”

用户让 AI 按顺序写出一到二十、每个数字配一句小知识。写到“3 ——”时,用户点了停止(断网也一样)。接着,用户没有点重试,而是直接发了一句“继续”。

这个场景分两段看:先是停止时发生了什么,然后是发“继续”时发生了什么。

调用链路:点停止,然后发“继续”。

image.png

顺序文件 · 方法做什么
1hooks/useTurnRunner.ts · stop()先把 stopRequested 设为 true,再调 abortRun() 断开第二步的连接,不调任何接口
2agent/ag_ui_adapter.py · stream()Starlette 发现客户端断开,取消事件流;取消分支里停掉后台任务、保存半截回答
3api/agent.py · SseResponse.__call__()断得更早、事件流还没开始时,stream() 不会执行,由这里调 adapter.abandon() 收尾
4runs/service.py · mark_stopped()执行 → cancelled,半截回答存成一条标了 partial 的 assistant 消息,这一轮 → cancelled
5hooks/useTurnRunner.ts · execute()@ag-ui/client 停止后不抛 AbortError,靠 stopRequested 认出这是用户停止的,状态记为 CANCELLED
6hooks/useTurns.ts · reload(id, waitForTurnId)Server 收尾要几十毫秒,这一轮还是 running 或 pending 就隔 100 毫秒再读,最多 20 次
7runs/service.py · _check_latest_turn_finished()发“继续”时检查上一轮:cancelled 可以提新问题
8runs/context_loader.py · _history_messages()停止和失败的轮次也带进上下文,回答末尾补一句“没有完成,请从中断的地方接着写”

停止时:保存已经写出的部分

前端:点停止时调 HttpAgent.abortRun() 断开第二步的连接,不需要调任何接口。停止之后前端还要做对两件事,否则页面会卡在“执行中”:认出这次执行是被用户停止的(@ag-ui/client 不会抛出 AbortError),以及等 Server 收尾完成再显示结果,细节见 8.11 节。

后端:适配器发现客户端断开,走 stream() 的取消分支:

客户端断开(点停止或断网)
└─ adapter.stream() 捕获 CancelledError / GeneratorExit
    └─ 在 CancelScope(shield=True) 里收尾,不会被再次取消:
        ├─ _stop_producer()                   原生 task.cancel(),LangGraph 正在进行的模型调用和工具立刻停下
        └─ service.mark_stopped(cancelled, partial_text = 已经推给前端的文字)
            ├─ repository.lock_turn()
            ├─ repository.finish_run()          running → cancelled,错误码 CLIENT_DISCONNECTED
            ├─ repository.insert_message()      把半截回答存成一条 assistant 消息,metadata 标记 partial
            └─ repository.update_turn_status_for_run()   这一轮 → cancelled

执行失败(failed)时走的是同一个 mark_stopped,同样会保存已经写出的部分。

还有一种更早的断开:执行已经被抢占为 running,但一个事件都还没推出去,客户端就断开了。这时 stream() 根本不会开始执行,上面的取消分支也就不会走到,由执行接口在响应结束时调用 adapter.abandon() 收尾,结果和普通断开一样(见 7.3 节)。

数据库变化:执行变成 cancelled;新增一条 assistant 消息,内容是“……3 ——”,metadata 是 {"partial": true};这一轮变成 cancelled。刷新页面后,这一轮显示问题、半截回答和“已停止”,并提示“可以点重试,或者直接发送‘继续’”。

发“继续”时:新增一轮,带上没写完的上一轮

前端:和场景 2 完全一样,ask(conversationId, "继续")。

后端第一步:prepare_new_turn 锁住会话后,先检查最新一轮的状态:

async def _check_latest_turn_finished(self, conn: AsyncConnection, conversation_id: str) -> None:
    """提新问题前检查最新一轮:执行中或等待审批时不能提新问题;领了凭证没执行的,作废它。

    调用前会话行已被锁住,这期间不会有人在这个会话里再开新的一轮。
    """
    latest = await self._repo.get_latest_turn(conn, conversation_id)
    if latest is None:
        return  # 会话里还没有任何一轮
    await self._repo.lock_turn(conn, latest["id"])  # 统一的加锁顺序:先轮次,再执行
    active = await self._repo.lock_run(conn, latest["active_run_id"])
    if active.status == RunStatus.RUNNING:
        raise RunInProgressError(active.id)
    if active.status == RunStatus.INTERRUPTED:
        raise TurnWaitingApprovalError(latest["id"])
    if active.status == RunStatus.QUEUED:
        # 例如第一步成功后断网,第二步没发出去:这张凭证作废,这一轮记为已停止
        await self._repo.cancel_queued_run(conn, run_id=active.id, error_code="ABANDONED")
        await self._repo.update_turn_status_for_run(
            conn, turn_id=latest["id"], run_id=active.id, status=TurnStatus.CANCELLED
        )
最新一轮当前的执行能不能提新问题
succeeded、cancelled、failed可以,本例是 cancelled
running:还在执行不行,409 RUN_IN_PROGRESS,要先停止
interrupted:等待审批不行,409 TURN_WAITING_APPROVAL,要先点允许或拒绝,或者编辑这一轮
queued:领了凭证没执行(比如两步之间断网)可以,旧凭证作废,那一轮记为已停止

检查通过后,和场景 2 一样新增一轮,问题是“继续”。

后端第二步:关键在装配上下文。context_loader 取最近 20 轮时,不只取已完成的,也取停止和失败的;没完成的轮次在回答末尾补一句说明:

def _history_messages(turns: list[HistoryTurn]) -> tuple[ChatMessage, ...]:
    """把历史轮次展开成消息列表;没完成的轮次在回答末尾(没有回答时单独一条)加上说明。

    这样用户停止后发“继续”,模型能看到上一轮的问题、自己写到哪里,以及“这里被中断了”。
    """
    messages: list[ChatMessage] = []
    for turn in turns:
        messages.extend(turn.messages)
        note = _UNFINISHED_NOTE.get(turn.status)
        if note is None:
            continue
        last = messages[-1]
        if last.role == "assistant":
            # 有半截回答:把说明接在它后面
            messages[-1] = replace(last, content=f"{last.content}\n\n{note}")
        else:
            # 一个字都没回答:补一条只有说明的回答,ID 按轮次生成,保证在上下文里唯一
            messages.append(replace(last, id=f"note-{turn.turn_id}", role="assistant", content=note))
    return tuple(messages)

所以模型这次实际看到的消息是:

(前面几轮:“我叫小明”“我叫什么”“用一句话介绍你自己”的问答,这里省略)
user       按顺序写出一到二十,每个数字单独一行,并在每个数字后面写一句关于它的小知识
assistant  1 —— ……
           2 —— 唯一的偶质数,也是所有质数中最小的一个。
           3 ——
           (这一轮的回答被用户停止或连接中断,没有完成。如果用户让你继续,请从中断的地方接着写,不要重复已经写过的内容)
user       继续

模型能看出上一轮写到“3 ——”被打断了,又有明确的指示,通常会从 3 接着往下写。说明的后半句(“请从中断的地方接着写”)是实测后加上的:只写“没有完成”时,模型有时会说“为保证完整,从头写一遍”,把 1、2 又重写一遍;加上这句后,实测两次都接着往下写了。这是提示词层面的引导,不是硬性保证,最终怎么写取决于模型。

那句说明只加在交给模型的上下文里,不写进数据库,页面上也看不到。

数据库变化:和场景 2 一样,新增一轮、一个问题“继续”、一次 normal 执行。上一轮保持 cancelled,半截回答仍然在页面上显示;它已经不是最新一轮,不能再编辑或重试了。

重试和继续有什么区别

重试(场景 4)发“继续”(场景 5)
轮次同一轮新增一轮
半截回答标为 replaced,被新回答替换保留,页面上照常显示
模型从哪开始从头重新回答这个问题看到半截回答,接着往下写
适合不想要这次的回答这次的回答有用,只是没写完

为什么不做“从断点接着执行” :另一种做法是让停止的那次执行从 LangGraph 的存档接着跑。这里没有这样做,因为停止可能发生在一个节点执行到一半的时候,存档里只有已经完成的节点,接着跑会把那个节点重做一遍;如果停在工具执行中,工具可能已经产生了副作用,重做一遍并不安全。用户说“继续”,本来就是让 AI 接着说,用上下文就能做到。

6.9 场景对比

场景 0 打开会话场景 1 新会话提问场景 2 已有会话提问场景 3 审批场景 4 编辑或重试场景 5 停止后发“继续”
第一步接口只有查询POST /api/turns,不带会话 IDPOST /api/turns,带会话 IDPOST /api/runs/resumePOST /api/turns/rerunPOST /api/turns,带会话 ID
Service 方法list_conversations、list_turnsprepare_new_turnprepare_new_turnprepare_resumeprepare_rerun停止时 mark_stopped,继续时 prepare_new_turn
会话不变新建不变不变不变(编辑第 1 轮时改标题)不变
轮不变新建新建复用复用新建,上一轮保持已停止
执行不变新建 normal新建 normal新建 resume新建 retry新建 normal
存档链无新建新建沿用被中断的新建新建
第二步无POST /api/agent同左同左,带 resume同左同左,上下文带着半截回答

6.10 多个会话同时执行

上面每个场景都发生在一个会话里。多个会话可以同时执行,执行中也能随时切换、新建会话;规则只有一条:同一个会话同时只有一轮在执行。

调用链路:会话 A 正在执行时,用户切到会话 B。

image.png

文件 · 方法做什么
hooks/useTurnRunner.ts执行状态按会话键分开存:views 放页面要显示的状态,sessions 放每个会话的 HttpAgent 和停止标记,running 记着哪些会话在执行
App.tsxcurrentKey 决定显示哪个会话;切换会话只改它,不影响正在跑的执行
hooks/useTurns.ts轮次按会话 ID 缓存,别的会话结束时刷新的是它自己的那一份
runs/service.py · prepare_new_turn()保证同一个会话同时只有一轮在执行
runs/repository.py · list_conversations()带上每个会话最新一轮的状态,会话栏据此显示“等待审批”

后端不用做任何事。不同会话的执行各用各的会话行锁、轮次行锁和存档链,互不影响;“同一个会话只有一轮在执行”本来就由 prepare_new_turn 保证,最新一轮还在执行时再提问返回 409 RUN_IN_PROGRESS。唯一的改动是会话列表带上每个会话最新一轮的状态(latest_turn_status),会话栏据此显示“等待审批”,刷新页面后也还在:

async def list_conversations(self, conn: AsyncConnection, user_id: str) -> list[ConversationRecord]:
    """左侧会话列表:当前用户未删除的会话,最近有新轮次的排在前面。

    顺带取出最新一轮的状态,会话栏据此显示“等待审批”;刷新页面后也能看到哪个会话停在审批。
    """
    cur = await conn.execute(
        """
        SELECT c.*, t.status AS latest_turn_status
        FROM conversation AS c
        LEFT JOIN conversation_turn AS t
               ON t.conversation_id = c.id AND t.sequence_no = c.last_turn_seq
        WHERE c.user_id = %s AND c.deleted_at IS NULL
        ORDER BY c.updated_at DESC
        LIMIT 100
        """,
        (user_id,),
    )
    return [ConversationRecord.from_row(row) for row in await cur.fetchall()]

前端的改动是把执行状态从“整个页面一份”改成“每个会话一份”,细节见 8.11 节。用户看到的效果:

  • A 会话正在回答时,点“新会话”或别的会话,A 在后台继续执行,会话栏上 A 显示“执行中”;切回 A,能接着看正在输出的回答,或者已经完成的结果。
  • A、B 同时执行时,在 A 里点停止,只停 A,B 照常完成。
  • A 停在审批时,B 照常提问、执行;回到 A 再点允许,也正常。
  • 在“新会话”里提了第一个问题,第一步还没返回就切到了别的会话:新会话照常创建、在后台执行,页面不会被拉回去。
  • 同时执行的会话最多 4 个,第 5 个会提示“请等其中一个结束,或者先停止一个”。原因见 8.11 节。
  • 刷新页面或关闭窗口时,所有正在执行的会话都会停止,各自保存已经写出的部分回答,和单个会话一样。

测试 test_two_conversations_run_at_the_same_time 覆盖后端:A 执行到一半时 B 照常完成;这期间在 A 里再提问返回 409;停止 A 后 A 记为已停止并保存半截回答,B 不受影响;会话列表带上各自最新一轮的状态。