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 章细讲):
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 | 说明 |
|---|---|---|---|
| 会话列表里的一项 | conversation | conversation_id | 同时是 AG-UI 协议里的 threadId |
| 一轮(一问一答) | conversation_turn | turn_id | 用户提一个新问题就新增一轮 |
| 问题、回答 | conversation_message | message_id | 只存用户问题和 AI 最终回答;编辑重跑后旧的问答标为 replaced,页面上就不显示了 |
| 看不到:一次执行 | agent_run | run_id | 同时是 AG-UI 的 runId,也就是一次流式请求 |
| 审批卡片 | agent_interrupt | interrupt_id | 执行停在审批处时记一条;处理后标为 resolved,编辑作废时标为 cancelled |
| 看不到:存档链 | LangGraph 的 Checkpoint 表 | graph_thread_id | Agent 执行到哪一步的存档,审批后“接着执行”靠它 |
它们的层级关系:
用户
└── 会话 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 | 等待执行 |
running | running | 流式显示回答,可以点停止 |
interrupted | interrupted | 显示审批卡片,输入框禁用 |
succeeded | completed | 显示回答 |
failed | failed | 执行失败,显示已经写出的部分回答;可以重试,或直接发“继续” |
cancelled | cancelled | 已停止,显示已经写出的部分回答;可以重试,或直接发“继续” |
三个准备方法都会把这一轮设为 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 执行的三个操作(提问、审批、编辑重跑)都要调两次接口:
- 准备:调对应的准备接口。Server 在一个数据库事务里把这一轮需要的记录建好(新会话时连会话一起建),创建一个排队中(
queued)的执行,把conversation_id、turn_id、run_id、user_message_id返回给前端。这一步只写数据库,不跑 Agent,几十毫秒就返回。 - 执行:前端拿着这些 ID 调
POST /api/agent。Server 按run_id从数据库查出这次执行的全部信息,真正运行 Agent,边执行边把文字、工具调用等事件用 SSE 推给前端,结束前把回答写进数据库。
可以把第一步理解为“挂号”,领到一张执行凭证 run_id;第二步是“拿着凭证去看病”。
下面是一次提问的两步:
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 | 提一个问题 | 执行过程怎么推回来 | 审批 | |
|---|---|---|---|---|
| Codex | thread/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 的顺序同时用到左右两边:
- 左边管记账:谁、哪个会话、哪一轮、执行了几次、结果如何,存在五张业务表里。这一半是 M4 新写的。
- 右边管干活:调用模型、执行工具、每走一步存档。这一半是 M3 的 LangGraph,只改了模型超时一处(见 7.9 节)。
- 中间的 适配器 是桥:进去之前把前端的输入换成数据库里的可信数据,出来之后把结果写回业务表。
5.2 模块和它们存在的理由
| 模块 | 位置 | 负责什么 | 为什么要单独成一块 |
|---|---|---|---|
| api | api/ | HTTP 层:路由、请求和响应模型、依赖注入、异常到状态码的映射 | 业务代码不认识 HTTP,以后 MCP 入口、后台任务也能直接调 Service |
| runs | runs/ | 业务模块:会话、轮次、消息、执行、中断的数据和规则 | 所有“能不能做、怎么记”的规则集中在这里,页面操作对应到它的一个个方法 |
| agent | agent/ | 执行引擎:M3 的 LangGraph 图,以及适配器 | 执行引擎不知道业务表的存在,换引擎不影响业务记录;适配器是两者之间唯一的连接点 |
| core | core/ | 配置、ID 生成、启动时检查迁移 | 所有模块都要用的基础设施 |
| 装配 | main.py | 启动时创建所有单例,挂到 app.state 上 | Python 没有 Spring 那样的自动注入,需要一个地方手动把对象装起来 |
| 迁移 | migrations/ | 五张业务表的建表脚本,按编号依次执行 | 表结构有版本记录,已有的数据库能按顺序升级 |
runs 模块内部再分层,每层只做一件事:
| 层 | 文件 | 做什么 | 不做什么 |
|---|---|---|---|
| 领域模型 | models.py、errors.py | 状态枚举、数据对象、业务异常 | 不含逻辑 |
| Repository | repository.py | 一个方法一条 SQL,把结果行转成对象 | 不判断能不能做 |
| Service | service.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-postgres | Agent 状态图,存档存进 PostgreSQL | 无 |
| ag-ui-protocol、ag-ui-langgraph | AG-UI 协议的数据模型;把 LangGraph 事件翻译成 AG-UI 事件的官方集成 | 无 |
| anyio | 客户端断开后仍能完成收尾(CancelScope) | 无 |
| uuid-utils | 生成按时间有序的 UUIDv7 | 无 |
@ag-ui/client | 前端的 AG-UI 客户端 HttpAgent:发请求、解析 SSE、按事件类型回调 | 无 |
本篇直接写 SQL,没有用 ORM。执行的状态流转依赖条件 UPDATE、行锁和部分唯一索引,直接写 SQL 最清楚每一步在数据库里做了什么,也能和 LangGraph 的存档共用一个连接池。
6. 场景:点一下按钮,代码从哪个文件走到哪个文件
这一章按用户实际的操作顺序,把代码串起来走一遍。例子是同一个会话:先问“我叫小明”,再问“我叫什么”,再让 AI 读取示例文本(需要审批),然后编辑这个问题,最后是回答到一半被停止、用户发“继续”。
每个场景按同样的顺序写:
- 调用链路图:从点击按钮开始,经过哪个文件的哪个方法,再到哪个文件的哪个方法。每个框写成“文件 · 方法:做什么”,箭头表示调用,箭头上写的是请求或者传回来的东西。
- 调用明细表:按调用顺序,一行一个方法:在哪个文件、做了什么。
- Service 里的调用树:第一步在一个事务里依次调用了 Repository 的哪些方法,缩进表示“被上一层调用”。
- 数据库变化和要注意的地方。
路径的写法:后端文件省略前缀 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 的依赖函数再把它们取出来,作为参数传给接口函数。后面说的“注入”,指的就是这一步。
| 文件 · 对象 | 做什么 | 谁用它 |
|---|---|---|
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 里的地址、模型名、密钥创建 ChatOpenAI | create_langgraph_runtime() |
agent/model_gateway.py · ModelGateway | bind_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.py | get_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 个接口画在一起:
最上面是启动时装好的对象,接着是每个请求都要经过的公共路径,然后一行一个接口。每一行左边是接口路径和接口函数,右边上排是大流程,按 ①②③ 的顺序执行;标题带 ↓ 的格子,用粗箭头展开成下面一排小流程。格子的颜色表示代码在哪一层;格子里蓝字是调用的方法,橙字是加锁,红字是出错直接返回。
图比较大,可以点开画板放大看。读的时候抓住这几点:
- 两个 只读 接口:
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。
调用链路:打开应用,点开一个会话。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | hooks/useConversations.ts · useEffect | 页面挂载时调 listConversations()。选中的会话 ID 存在 localStorage,重启应用后自动打开上次的会话 |
| 2 | agent/devmind-api.ts · listConversations() → requestJson() | 带上开发期身份头发请求;非 2xx 抛 ApiError,连不上 Server 抛“连接 Server 失败” |
| 3 | api/conversations.py · list_conversations() | FastAPI 先执行依赖 get_current_user()、get_run_service(),再调 Service |
| 4 | runs/service.py · RunService.list_conversations() | 只读,借一个连接、不开事务。里面调 repository.list_conversations():当前用户的会话按 updated_at 倒序,带上每个会话最新一轮的状态 latest_turn_status |
| 5 | hooks/useConversations.ts · select(id) | 点会话时调用:写进 localStorage,更新 selectedId |
| 6 | hooks/useTurns.ts · useEffect | selectedId 变了就调 listTurns(),结果按会话 ID 缓存。返回 404 或 403(会话已经不存在)时调 onMissing,页面回到“新会话”,而不是一直显示报错 |
| 7 | api/conversations.py · list_turns() | conversation_id 从查询参数取,缺了返回 422 |
| 8 | runs/service.py · RunService.list_turns() | 里面依次调 _check_conversation_owner()(→ repository.get_conversation())校验归属、repository.list_turn_views() 取每轮的视图,最后补 is_latest、can_rerun |
| 9 | App.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 上还什么都没有。
调用链路:新会话里提第一个问题。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | components/Composer.tsx | 点发送,调 onSend(content) |
| 2 | App.tsx | 调 runner.ask(currentKey, selectedId, content)。新会话时 selectedId 是 null,currentKey 是草稿键 draft:… |
| 3 | hooks/useTurnRunner.ts · ask() → prepareThenExecute() | 这个会话上一个操作还没结束就忽略这次点击;同时执行的会话已满 4 个就提示;状态设为 PREPARING |
| 4 | agent/devmind-api.ts · createTurn() | 用新生成的幂等键 POST /api/turns,body 里没有 conversation_id |
| 5 | api/conversations.py · create_turn() | CreateTurnRequest 校验请求体(不通过直接 422),注入用户和 RunService,调 prepare_new_turn() |
| 6 | runs/service.py · RunService.prepare_new_turn() | 在一个事务里建会话、轮、问题、执行,见下面的调用树 |
| 7 | hooks/useTurnRunner.ts · moveKey() | 执行状态从草稿键挪到真正的会话 ID c1 下 |
| 8 | App.tsx · onPrepared() | 用户还停在这个新会话页面,就切到 c1,这一轮先以“等待执行”显示出来;已经切走了就不动。然后刷新会话列表 |
| 9 | hooks/useTurnRunner.ts · execute() | createDevmindAgent("c1") 新建一个 HttpAgent(threadId 是 c1),唯一一条消息的 ID 用返回的 user_message_id,调 runAgent({ runId: "r1" }) 发起第二步;事件回调把回答一段段追加到 streamingText |
| 10 | App.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 相同的部分合成了一个框):
| 文件 · 方法 | 和场景 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,显示审批卡片,输入框禁用。
用户点“允许”。
调用链路:从停在审批开始,到点允许后工具执行完。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | agent/langgraph_runtime/graph.py · request_approval() | r3 执行时模型要调 demo_text,route_after_model() 把它送到审批节点,interrupt() 暂停并存档 |
| 2 | agent/ag_ui_adapter.py · _persist_finished() | 收到 RUN_FINISHED 后读存档,发现有中断,调 service.mark_interrupted() |
| 3 | runs/service.py · mark_interrupted() | insert_interrupt() 写入 i1(open),r3 和 t3 标为 interrupted |
| 4 | App.tsx · onSettled() → hooks/useTurns.ts · reload() | 最新一轮带着 pending_approval,TurnCard 显示审批卡片,输入框禁用 |
| 5 | components/TurnCard.tsx | 点允许,调 onApprove("i1", true) |
| 6 | hooks/useTurnRunner.ts · approve(currentKey, "i1", true) | 第一步用 resumeRun;第二步不带用户消息,带 resume: [{interruptId: "i1", status: "resolved", payload: {approved: true}}] |
| 7 | agent/devmind-api.ts · resumeRun() | 幂等键固定为 approval:i1,同一张卡片点几次都是同一个键 |
| 8 | api/runs.py · resume_run() | ResumeRequest 校验请求体,调 prepare_resume() |
| 9 | runs/service.py · prepare_resume() | 见下面的调用树 |
| 10 | runs/context_loader.py · load_agent_run_context() | r4 是 resume 执行:对话已经在存档链 g3 里,不读历史;get_interrupt() 读出 i1 的审批结果 |
| 11 | agent/ag_ui_adapter.py · _build_internal_input() | 消息为空,resume 按数据库里的审批结果重新生成,前端传来的 payload 不采信 |
| 12 | graph.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_interrupt | i1:open → resolved,resolution_payload = {"approved": true} |
conversation_turn | t3 的当前执行: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 不存在。)
调用链路:编辑最新一轮,保存并重新运行。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | components/TurnCard.tsx | 只有 can_rerun 为真(最新一轮、不在执行中)时才能编辑和重试。保存时调 onRerun(turn, content);点“重试”时不传 content,问题不变,其余完全一样 |
| 2 | hooks/useTurnRunner.ts · rerun(currentKey, turn, content) | 第一步用 rerunTurn;第二步的问题是新内容,重试时是原问题 |
| 3 | agent/devmind-api.ts · rerunTurn() | POST /api/turns/rerun,body 是 turn_id、content 和新生成的幂等键 |
| 4 | api/conversations.py · rerun_turn() | RerunTurnRequest 校验请求体,调 prepare_rerun() |
| 5 | runs/service.py · prepare_rerun() | 见下面的调用树 |
| 6 | hooks/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_interrupt | i1:open → cancelled,之后再点这张审批卡片会得到 409 |
conversation_message | 旧问题“读取示例文本”:completed → replaced;新增 m6“用一句话介绍你自己” |
agent_run | 新增 r5:retry,trigger_type = edit,parent_run_id = r3,存档链 g5(新的) |
conversation_turn | t3:问题换成 m6,当前执行换成 r5,状态回到 pending;不新增轮次 |
第二步:和普通提问一样执行。上下文是前两轮的问答加上新问题;旧问题和旧回答已经是 replaced,不会进入上下文,页面上也不再显示。
为什么旧问答是“标记”而不是删除:页面和模型只读 completed 的消息,旧的自然就看不到了;但数据库里还留着,排查时能看到这一轮被改过几次、每次执行的结果是什么。
6.8 场景 5:回答到一半停止或断线,然后发“继续”
用户让 AI 按顺序写出一到二十、每个数字配一句小知识。写到“3 ——”时,用户点了停止(断网也一样)。接着,用户没有点重试,而是直接发了一句“继续”。
这个场景分两段看:先是停止时发生了什么,然后是发“继续”时发生了什么。
调用链路:点停止,然后发“继续”。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | hooks/useTurnRunner.ts · stop() | 先把 stopRequested 设为 true,再调 abortRun() 断开第二步的连接,不调任何接口 |
| 2 | agent/ag_ui_adapter.py · stream() | Starlette 发现客户端断开,取消事件流;取消分支里停掉后台任务、保存半截回答 |
| 3 | api/agent.py · SseResponse.__call__() | 断得更早、事件流还没开始时,stream() 不会执行,由这里调 adapter.abandon() 收尾 |
| 4 | runs/service.py · mark_stopped() | 执行 → cancelled,半截回答存成一条标了 partial 的 assistant 消息,这一轮 → cancelled |
| 5 | hooks/useTurnRunner.ts · execute() | @ag-ui/client 停止后不抛 AbortError,靠 stopRequested 认出这是用户停止的,状态记为 CANCELLED |
| 6 | hooks/useTurns.ts · reload(id, waitForTurnId) | Server 收尾要几十毫秒,这一轮还是 running 或 pending 就隔 100 毫秒再读,最多 20 次 |
| 7 | runs/service.py · _check_latest_turn_finished() | 发“继续”时检查上一轮:cancelled 可以提新问题 |
| 8 | runs/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,不带会话 ID | POST /api/turns,带会话 ID | POST /api/runs/resume | POST /api/turns/rerun | POST /api/turns,带会话 ID |
| Service 方法 | list_conversations、list_turns | prepare_new_turn | prepare_new_turn | prepare_resume | prepare_rerun | 停止时 mark_stopped,继续时 prepare_new_turn |
| 会话 | 不变 | 新建 | 不变 | 不变 | 不变(编辑第 1 轮时改标题) | 不变 |
| 轮 | 不变 | 新建 | 新建 | 复用 | 复用 | 新建,上一轮保持已停止 |
| 执行 | 不变 | 新建 normal | 新建 normal | 新建 resume | 新建 retry | 新建 normal |
| 存档链 | 无 | 新建 | 新建 | 沿用被中断的 | 新建 | 新建 |
| 第二步 | 无 | POST /api/agent | 同左 | 同左,带 resume | 同左 | 同左,上下文带着半截回答 |
6.10 多个会话同时执行
上面每个场景都发生在一个会话里。多个会话可以同时执行,执行中也能随时切换、新建会话;规则只有一条:同一个会话同时只有一轮在执行。
调用链路:会话 A 正在执行时,用户切到会话 B。
| 文件 · 方法 | 做什么 |
|---|---|
hooks/useTurnRunner.ts | 执行状态按会话键分开存:views 放页面要显示的状态,sessions 放每个会话的 HttpAgent 和停止标记,running 记着哪些会话在执行 |
App.tsx | currentKey 决定显示哪个会话;切换会话只改它,不影响正在跑的执行 |
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 不受影响;会话列表带上各自最新一轮的状态。