前言 :封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,最主要的是用户认可产品。尽量用几篇文章讲清,这篇文章是系列第5篇:多步、变慢、要确认时,前端不能靠猜。把 Run、中断、恢复和流式事件定成前后端都能认的协议。
用户说:"重启这个异常实例。" 或中间补一句:"继续查它。"
这篇只讲:一次请求怎么流式推给前端,以及人确认之后怎么从断点接着跑。
单轮聊天接口不难。业务一接进来,通常马上会碰到这三件事:
- 用户说「继续查它」,系统不知道「它」是谁;
- Agent 跑很久,前端长时间没反馈;
- 删除、发布、转账必须让人确认,确认后还要从中断处继续。
这三件事分别对应:多轮状态、SSE 流式通信、HITL(Human in the Loop,人在回路)。继续用案例 OpsPilot(企业运维助手,虚构案例),技术栈仍是 TypeScript;前端侧可按 Vue 3 + fetch 读流来接。

一次运行要有自己的状态
不要只存一串聊天文本。至少分成三张表(或三份记录)来存:
- 会话(Conversation):左边那一列聊天列表里的一项,比如「排障 - payment-api」。一个人可以有很多个会话。
- 消息(Message):会话里的每一句:用户说的、助手回的、工具结果,各算一条。
- 运行(Run):用户点一次发送,后台为这句话开的一次任务(跑模型、调工具、等人确认)。同一会话里可以有很多次 Run。
ts
type Conversation = { // 一次聊天会话(聊天列表里的一项)
id: string // 会话 id
userId: string // 属于哪个用户
createdAt: string // 创建时间
}
type Message = { // 会话里的一条消息
id: string // 消息 id
conversationId: string // 属于哪个会话
role: 'user' | 'assistant' | 'tool' // 谁说的
content: string // 正文(展示 / 写进数据库)
createdAt: string // 创建时间
}
type Run = { // 用户点一次发送对应的一次后台运行
id: string // Run id
conversationId: string // 落在哪个会话
userMessageId: string // 由哪条用户消息触发
status: // 这次运行进行到哪
| 'running' // 执行中
| 'interrupted' // 等人确认 / 补参
| 'completed' // 正常结束
| 'failed' // 失败
| 'cancelled' // 取消
checkpoint?: unknown // 中断后接着跑所需的最小状态
createdAt: string // 创建时间
updatedAt: string // 状态最后变更时间
}
一次用户请求对应一个 Run。用户确认后恢复的是这次运行,不用重新猜整段对话发生了什么。
三张表管「存哪」;下面三层管「每次喂模型什么」。
先能听懂「它」,别整段塞历史。 用户常说「继续查它」「刚才那个」。别把整段聊天记录都扔进模型;拼上下文时用三层就够:
- 已确认的事实 → 结构化
state(挂在会话上:Conversation 的字段或关联表);只在工具成功或用户明确确认后写入,查找失败不要把猜的写进来。 - 更早的对话 → 压成一段摘要,控制长度。
- 最近几句原文 → 谁说了什么原样保留,别先压成摘要,不然模型不知道「它」是谁。
对应到一次模型调用:第 1、2 层放进 system;第 3 层作为最近消息列表接在后面(不是把 messages 全表倒进去):
ts
// 1) 已确认事实(挂在会话上)
const state = { service: 'payment-api', env: 'prod' as const }
// 2) 更早对话的摘要
const earlierSummary = '用户在排查生产环境报错;服务名与环境已确认。'
// 3) 最近几句原文(含当前这句「继续查它...」)
const recentMessages = [
{ role: 'user', content: '看下 payment-api 最近的错误' },
{ role: 'assistant', content: '生产环境过去 15 分钟有 12 次 5xx。' },
{ role: 'user', content: '继续查它最近的错误日志' },
]
// 拼装:1+2 → system;3 → 消息列表
const modelInput = [
{
role: 'system',
content:
`已知事实:${JSON.stringify(state)}\n` +
`更早摘要:${earlierSummary}\n` +
`用最近原文理解「它 / 刚才那个」是谁;不要靠关键词硬猜。`,
},
...recentMessages,
]
先看一条完整链路(SSE + HITL)
用 OpsPilot 举例:用户说「重启这个异常实例」。整件事分两段------先查清、等人点确认 ;点完再真重启。
第一段:发消息、看进度、弹出确认
- 前端把这句话发给会话服务,同时打开一条 SSE,用来收进度(开始了、正在查、查完了......)。
- 后台先跑只读步骤(比如定位是哪个实例),这些进度通过 SSE 推到页面。
- 模型提出要调「重启」工具。这时还不执行重启 ,而是发一条
interrupt事件:页面弹出确认(风险说明、要重启谁)。 - 这次 Run 记成「等人」;这一轮推流可以结束。页面上是确认,不是红字报错。
第二段:用户点确认,从断点接着跑
- 用户点「确认」。前端调用恢复接口(带上这次中断的 id、同意/拒绝、以及防双击用的同一把请求键),不是再发一条全新聊天。
- 服务端先确认:这个中断还有效、当前用户仍有权限、实例现在还是那个状态;通过后才真正重启。
- 执行过程再次用 SSE 推进度(工具开始/结束、最终回答),直到结束。
就记两句:
- 等人确认用
interrupt,这是正常流程,不是失败。 - 点确认后走恢复接口接着这次 Run 跑,不要当成新开一轮对话。

SSE 流式推送
SSE(Server-Sent Events)基于普通 HTTP:前端发一次请求,服务端持续推文本事件。「用户发一句,服务端持续回进度」时通常比 WebSocket 简单;要高频双向或二进制时再评估 WebSocket。
有的编排框架每次推过来的不是「刚发生的那一步」,而是「到目前为止的全部状态」(例如步骤列表又完整发一遍)。这时前端或网关要自己对比上次看过的内容,只把新多出来的那几步 转成 tool_started 这类事件;否则页面上同一步会显示两遍。
下面先定业务事件,再套 SSE 外壳;长连接上的注释行防超时、断开后重新连接,放在本节末。
事件协议
- 流必须是 UTF-8 ;MIME 为
text/event-stream(可写成text/event-stream; charset=utf-8)。 - 建议每条业务事件里带一个本 Run 内越来越大的序号,比如
sequence: 1, 2, 3...。前端拿它判断「这条是不是已经渲染过」;以后若要「断开后把没收到的进度再拉回来」,也靠这个号。注意:字段先留着,不代表第一版就要做这件事;另外,这是你们自己 JSON 里的字段,和 SSE 协议自带的id:不是一回事。
ts
import type { ServerResponse } from 'node:http'
type StreamEvent =
| {
type: 'run_started'
runId: string
sequence: number // 本 Run 内 1,2,3...;防重复渲染,以后也可按号把没收到的进度拉回来
}
| {
type: 'text_delta'
runId: string
sequence: number
delta: string // 增量字,不是全量覆盖
}
| {
type: 'tool_started'
runId: string
sequence: number
callId: string // 与 tool_finished 配对
tool: string // 工具名,步骤条展示
}
| {
type: 'tool_finished'
runId: string
sequence: number
callId: string
tool: string
status: 'success' | 'error'
summary?: string // 给前端的短摘要,不是原始大结果
}
| {
type: 'interrupt'
runId: string
sequence: number
interruptId: string // resume 时原样带回
payload: unknown // 确认:风险说明、参数摘要等(已脱敏)
}
| {
type: 'done'
runId: string
sequence: number
messageId: string // 已写入数据库的助手消息 id
}
| {
type: 'error'
runId: string
sequence: number
code: string
message: string // 可展示给用户;不要堆栈
}
// 片段:在已创建 Run、准备推流的路由里调用(res 是 Node/Express 响应,不是 fetch Response)
function openSse(res: ServerResponse) {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('X-Accel-Buffering', 'no') // 仅 nginx;不用可省略
res.flushHeaders()
}
function sendEvent(res: ServerResponse, event: StreamEvent) {
// 一条 SSE 消息:event 行 + data 行 + 空行结束
res.write(`event: ${event.type}\n`)
res.write(`data: ${JSON.stringify(event)}\n\n`)
}
// 需要等人时:先把 interrupt 和 Run=interrupted 写进数据库,再推事件(示意)
function emitInterrupt(
res: ServerResponse,
runId: string,
sequence: number,
interrupt: { id: string; payload: unknown }
) {
sendEvent(res, {
type: 'interrupt',
runId,
sequence,
interruptId: interrupt.id,
payload: interrupt.payload
})
// 随后可以 res.end() 结束本轮流;用户确认走 POST resume,不必占用同一条长连接
}
事件类型应带业务含义。不要让前端解析模型输出文本去猜「现在是不是在调工具」。
说明:若改用浏览器原生 EventSource,带了 event: 字段的消息不会 进 onmessage,必须 addEventListener(事件名, ...)。本文用 fetch 自解析时,是靠 JSON 里的 type 分发,和 EventSource 的命名事件机制不是同一条路径。
前端怎么读流、怎么接到 interrupt
原生 EventSource 只能 GET、不能带请求体和自定义头(跨域最多 withCredentials 带 Cookie)。聊天要 POST,所以用 fetch 读流、自己切事件。
为什么必须用 buffer(先攒起来再切,不能来一段就当完整事件):
- 网络一次读到的不一定是完整一条------可能只半截,也可能一次来好几条。
- 事件之间靠「空一行」分开。
- 空行怎么写因环境而异:有的是
\n,有的是\r,有的是\r\n(Windows 常见)。 - 解析时先统一收成
\n,再按连续两个换行\n\n切开。
下面骨架只负责拆包并回调;UI 自行接 onEvent。
ts
// 可运行骨架:POST 聊天并解析 SSE
async function streamChat(
body: unknown,
onEvent: (event: StreamEvent) => void
) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok || !response.body) {
throw new Error(`请求失败:${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder() // SSE 规定 UTF-8
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
let boundary: number
while ((boundary = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
// 「: ...」是注释行,没有 data:,得到空串就跳过
const data = block
.split('\n')
.filter(line => line.startsWith('data:'))
.map(line => {
const value = line.slice(5)
return value.startsWith(' ') ? value.slice(1) : value
})
.join('\n')
if (data) onEvent(JSON.parse(data) as StreamEvent)
}
}
}
// 接到事件后怎么处理(示意,不是完整 UI)
function handleStreamEvent(event: StreamEvent) {
switch (event.type) {
case 'text_delta':
appendAssistantText(event.delta) // 追加字,不要整段替换
break
case 'tool_started':
showStep(event.callId, event.tool, 'running')
break
case 'tool_finished':
finishStep(event.callId, event.status, event.summary)
break
case 'interrupt':
// 正常等待人:弹确认;保存 interruptId 供 resume
openConfirmCard(event.interruptId, event.payload)
break
case 'done':
markRunDone(event.messageId)
break
case 'error':
showError(event.code, event.message)
break
default:
break
}
}
// 用户点确认:另发 resume,不是再 POST 一条「新聊天」
async function approveInterrupt(runId: string, interruptId: string) {
await fetch(`/api/runs/${runId}/resume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interruptId,
decision: 'approve',
idempotencyKey: `resume_${interruptId}_1` // 同一确认动作固定一键,防双击
})
})
// resume 若仍用 SSE 推进度:再发一次请求把 SSE 接上,或按你们约定复用同一条连接
}
连接别被空闲断开;断了也别指望浏览器替你补进度
推流时若半天没有业务事件,nginx / 网关常会按「空闲太久」把连接断开。做法很简单:隔一段时间发一行以 : 开头的注释(不是业务事件,前端解析时直接跳过):
text
: keep-alive
间隔自己对着网关超时测(常见从 15~30 秒试起;nginx 的 proxy_read_timeout 默认往往是 60 秒)。
本文用的是 POST + fetch 读流,要心里有数:
- 连接断了,浏览器不会自动再连,也不会帮你把没收到的进度拉回来。
- 第一版优先保证:Run 状态在数据库里、断开或刷新后还能查到最终结果和确认卡;页面要继续看进度,就再发一次请求把 SSE 接上。
- 以后若要做「断开之后,把没收到的进度再向服务端拉回来」,靠 JSON 里的
sequence告诉服务端「我已经收到几号了,从下一号继续给我」------前提是这些事件当时已经存进数据库;没存就拉不回来。
补充一句:浏览器原生的 EventSource 往往会自动重连,并带上 Last-Event-ID。那也只是告诉服务端「我上次收到哪条」;浏览器照样不会自动把中间没收到的进度补全 ,逻辑仍在服务端。聊天要 POST,本文不以 EventSource 为主路径。

HITL:暂停等人,不要当成报错
缺参数、要审批等时,这次 Run 应记成 interrupted(暂停等人),不要 记成 failed。前端展示「等你确认 / 请补参数」,不要甩红字失败。
暂停时,至少留下两样东西:
- 一条中断记录(给前端弹确认卡、恢复时对得上号);
- 一份断点(确认后从哪一步接着跑)。
中断记录与断点
ts
type Interrupt = { // 中断记录(等人确认 / 补参)
id: string // 中断 id,恢复时必须原样带回
runId: string // 所属 Run
type: 'confirm_action' | 'select_option' | 'provide_input' // 确认 / 选择 / 补参
status: 'pending' | 'resolved' | 'expired' | 'cancelled' // 只有 pending 才能恢复
payload: unknown // 确认卡上要展示的内容(已脱敏)
expiresAt: string // 过了这个时间不能再点「同意」
}
type Checkpoint = { // 断点:继续跑所需的最小状态
runId: string
nextStep: string // 恢复后从哪一步继续
safeToolArgs: Record<string, unknown> // 已脱敏、恢复前还要再鉴权的参数
conversationVersion: number // 防止恢复期间会话被别人改乱
createdAt: string
}
payload 里不要塞数据库连接串、访问令牌、完整工具参数或内部堆栈。断点也只留继续跑真正需要的字段。
恢复接口
用户点「确认 / 拒绝 / 补参」时,打恢复接口,不是再发一条新聊天:
ts
// POST /api/runs/:runId/resume 请求体
{
interruptId: 'int_456', // 仍为 pending 的中断 id
decision: 'approve', // approve | reject | 补参等
idempotencyKey: 'resume_int_456_1' // 同一确认动作固定一把键,防双击
}
服务端不能直接信中断里那份旧参数。下面骨架里的 authorize / reloadResource / continueFrom 换成你们自己的实现即可,顺序不要乱:
ts
type ResumeInput = {
runId: string
interruptId: string
decision: 'approve' | 'reject' | { fill: Record<string, unknown> }
idempotencyKey: string
}
// 1) 找到仍 pending 的中断 2) 用当前登录态鉴权 3) 防重复提交
// 4) 拒绝则只改状态 5) 重读资源 6) 再验权限 7) 从断点续跑
async function resumeRun(input: ResumeInput, auth: AuthContext) {
const interrupt = await interruptRepo.findPending(input.interruptId)
if (!interrupt || interrupt.runId !== input.runId) {
throw new Error('INTERRUPT_NOT_FOUND')
}
await authorize(auth, interrupt) // 身份只信这次请求的登录态
await assertNotExpired(interrupt) // 看 expiresAt;过期直接拒绝
await idempotency.acquire(input.idempotencyKey) // 同一把键第二次进来:返回上次结果,别再执行
if (input.decision === 'reject') {
await markRejected(interrupt) // 只改状态
return { status: 'cancelled' as const } // 不要再调会改数据的工具
}
const checkpoint = await checkpointRepo.load(input.runId)
const latestResource = await reloadResource(checkpoint) // 等待期间实例可能已变
await revalidate(latestResource, auth)
return continueFrom(checkpoint, input.decision) // 按 nextStep 续跑
}
从弹出确认到用户点确认,资源和权限都可能变。所以,必须重读资源、重新鉴权。用户点拒绝时,只更新中断和 Run 状态,不要再去执行写操作。
记得要防双击、防重试打两次:
比如说:用户连点「确认」、网络抖一下浏览器自动重试、两个标签页各点一次,恢复接口都可能被打两遍。
所以我们的目标是:同一次确认,后面的写操作只真正执行一次。(在 OpsPilot 里就是「重启实例」只发生一次,不要重启两遍,这种动作很危险。)
常见两道防线,都写在恢复接口的后端代码里,由程序去访问数据库------不是让你打开数据库客户端手敲:
-
请求键
idempotencyKey:前端对「这一次点确认」生成一把固定的键(同一确认动作、连点、自动重试都带同一把)。服务端先查:这把键是不是已经处理过?处理过就直接返回上次结果,后面的重启逻辑不再跑。 -
改中断状态时加条件 :真正去执行写操作之前,用代码执行一条带条件的更新------「只有这条中断现在还是
pending,才改成resolved」。两个请求几乎同时进来时,数据库里只会有一个更新成功(改动行数 = 1);另一个改动行数 = 0,说明已经有人抢先处理了,这个请求就不要再调重启工具。
ts
// 写在 resume 接口里,通过数据库客户端执行(示意)
const result = await db.query(
`UPDATE interrupts
SET status = 'resolved', resolved_at = NOW()
WHERE id = $1 AND status = 'pending'`,
[interruptId]
)
if (result.rowCount === 0) {
// 已经处理过、过期或取消了 → 直接返回,禁止再执行工具
return alreadyHandledResponse()
}
// rowCount === 1:这是第一个抢到的请求,才允许继续重读资源、执行重启
记住一点就行:两个请求一起来时,靠上面这条带条件的更新,让数据库选出第一个成功的;只有它才能继续执行危险操作,另一个直接返回。
SSE 断了,后台要不要停
和「用户点取消」不是一回事。推流断开只表示前端暂时听不到进度;Run 要不要跟着停,看任务性质:
- 查询类:客户端断开后可以取消,省模型和工具费用。
- 发布、审批、重启实例等:通常应和 HTTP 连接拆开------后台 Run 继续跑,结果写进数据库;用户刷新后再把状态读回来。
ts
// 默认只停推送;是否取消 Run 看策略
// Node:别听 req 的 close(POST 读完请求体就可能触发);听 res 的 close
// 正常 res.end() 也会触发 close:用 !writableFinished 区分「客户端提前断开」
res.on('close', () => {
subscriber.detach(runId) // 停推送;不等于用户点了取消
if (!res.writableFinished && runPolicy.cancelWhenNoSubscriber) {
// 仅客户端提前断开;写操作 / 审批类通常不因断连取消
runController.cancel(runId)
}
})

和 LangGraph 怎么对照(点到为止)
不少团队用 LangGraph 跑 Agent 循环。它和本文解决的是同一类问题,分工不同:
- 本文定的是对外协议:会话、Run、SSE 业务事件、恢复接口怎么跟前端约定。
- LangGraph 管的是图怎么跑、中途状态怎么存。函数名、参数以你安装的版本文档为准,这里只记概念,避免版本一变例子就废。
| 本文 | LangGraph 里常见对应(概念) |
|---|---|
| Run + 断点写进数据库,中断后还能接着跑 | checkpointer 存图状态;用稳定的线程 id(常叫 thread_id)找回这份进度 |
| 同一会话里多次发送、状态接着用 | 同一条线程 id 上反复调图;一次性任务就每次换新 id |
| HITL:挂起等人,确认后再继续 | 节点里 interrupt,人补完后再 resume;挂起前要先开好 checkpointer |
| SSE 推「刚发生的一步」 | 图往往吐的是「到目前为止的全部状态」;网关/前端要自己对比,只把新多出来的步写成业务事件,避免同一步推两遍 |
| 恢复时重新鉴权、防双击、确认事件 | 框架不替你做;仍是会话服务 / Tool Runtime 的事 |
一句话:可以用 LangGraph 跑循环、存断点,但 SSE 事件名、恢复接口、鉴权和防双击仍要按本文自己定------接上框架不等于这些都齐了。
怎么测、常见误区与落地顺序
先测这些(上线前尽量跑一遍):
- SSE:一个事件被拆成好几块、一块里塞了多条、中文跨块切开、网关空闲超时、断网、刷新页面、重连后同一步是不是又画了一遍。
- HITL:双击「确认」、确认过期、恢复前权限被撤、实例在等待期间被别人改过、取消之后又点恢复。
- 会话上下文(顺带) :「它 / 刚才那个」有没有认对;工具失败后有没有误写入
state。
落地顺序(建议,不是行业标准):
- 先做 POST + SSE、统一业务事件、前端 buffer 解析;消息和工具结果写进数据库。
- 再上 Run、中断、断点、独立恢复接口、防双击、恢复前重新鉴权。
- 事件序号、断开后按号把没收到的进度拉回来、多实例下的进度推送、摘要与会话
state,可以后做。
刷新或断线之后,你还能查到任务停在哪、确认卡还能不能点、恢复前该重新验什么------这才比较接近能上线。

小结
常见误区:
- 把「等人确认」当成报错:报错是没法继续;中断是在等用户。页面、状态和恢复方式都不一样。
- 等待状态只放内存:进程一重启或一扩容就丢。Run、中断记录、断点都要写进数据库。
- 确认后直接拿旧参数去执行:等待期间资源和权限都可能变,恢复前要重读、再验。
- 把 SSE 当成任务本身:SSE 只负责推进度;任务停在哪,以数据库里的 Run 为准。
- 整段聊天记录塞进模型:贵,还容易被旧话带偏(见上文三层拼装)。
就这三条:
- 进度用 SSE 推业务事件(开始了、在调工具、弹出确认),别让前端靠读自然语言猜现在走到哪。
- 等人确认是正常流程,不是报错;用户点确认后走恢复接口,带着
interruptId接着这次 Run 跑,不要当成新开一轮对话。 - 连接断了不等于任务没了:Run / 中断 / 断点要写进数据库;恢复前重新验权限、重读资源,并防双击执行两遍。
希望这篇文章对你有所帮助、有所借鉴。