前一篇聊到一个我很认同的方向:复杂 Agent 不能只靠聊天窗口展示状态。
这篇不继续讲概念,直接做一个能跑的最小版本。
目标很简单:
Spring Boot后端 →持续产生Agent事件 →SSE推给浏览器 →React Flow实时更新节点 →右侧显示Artifact、预算和待审批事项不是为了做一个漂亮 Demo,而是验证一个设计:Agent UI 应该是运行状态的投影,而不是把日志重新排版。
最终要看到什么
假设 Agent 正在执行“升级 Java 25”任务。
前端节点:
Assess DONE Plan DONE Modify RUNNING Test PENDING Review PENDING Deploy BLOCKED事件进来以后,节点颜色和标签实时更新。
右侧同时显示:
Changed files: 6 Tests: 128 passed / 3 failed Model calls: 14 Cost: $2.31 Approval: deploy-canary用户不需要翻聊天记录。
技术栈
前端使用当前 React Flow 包:
npminstall@xyflow/react后端用 Spring WebFlux 的ServerSentEvent。
为什么这里选 SSE,不选 WebSocket?
因为 Run Canvas 的主要实时方向是:
Server → Browser用户的审批、取消、重试仍然可以走普通 HTTP。
SSE 足够简单。
如果后面需要高频双向协作,再换 WebSocket。
先定义事件,不要先写 UI
这是最重要的一步。
publicenumAgentRunEventType{RUN_CREATED,PLAN_CREATED,STEP_STARTED,STEP_COMPLETED,STEP_FAILED,ARTIFACT_CREATED,APPROVAL_REQUESTED,APPROVAL_RESOLVED,BUDGET_UPDATED,RUN_COMPLETED,RUN_FAILED}事件结构:
publicrecordAgentRunEvent(StringeventId,StringrunId,longsequence,AgentRunEventTypetype,StringstepId,JsonNodepayload,InstantoccurredAt){}sequence很重要。
不要只用时间戳排序。
多个 Worker 的时钟不一定严格一致。
事件应该先持久化,再推送
错误设计:
Agent执行 →直接SSE推前端用户刷新页面以后,前面的状态全没了。
正确设计:
Agent执行 →Event Store →Projection →SSE页面第一次打开:
GET /runs/{runId}拿当前 Projection。
然后:
GET /runs/{runId}/events订阅增量。
Projection 模型
publicrecordAgentRunView(StringrunId,Stringtitle,RunStatusstatus,longlastSequence,List<StepView>steps,List<ArtifactView>artifacts,BudgetViewbudget,List<ApprovalView>approvals){}Step:
publicrecordStepView(StringstepId,Stringname,StepStatusstatus,List<String>dependencies,Stringsummary,InstantstartedAt,InstantcompletedAt){}Event Store 最小表
createtableagent_run_event(event_idvarchar(128)primarykey,run_idvarchar(128)notnull,sequencebigintnotnull,event_typevarchar(64)notnull,step_idvarchar(128),payload jsonbnotnull,occurred_at timestamptznotnull,unique(run_id,sequence));createindexidx_run_event_run_seqonagent_run_event(run_id,sequence);生产系统中,Sequence 应由数据库、Run Actor 或专门序列服务生成,不能用:
System.currentTimeMillis()凑。
Spring Boot 的 SSE Endpoint
@RestController@RequestMapping("/api/runs")publicclassAgentRunStreamController{privatefinalAgentRunEventStreamstream;publicAgentRunStreamController(AgentRunEventStreamstream){this.stream=stream;}@GetMapping(value="/{runId}/events",produces=MediaType.TEXT_EVENT_STREAM_VALUE)publicFlux<ServerSentEvent<AgentRunEvent>>events(@PathVariableStringrunId,@RequestHeader(value="Last-Event-ID",required=false)StringlastEventId){returnstream.subscribe(runId,lastEventId).map(event->ServerSentEvent.builder(event).id(event.eventId()).event(event.type().name()).build());}}不要只用一个内存Sinks.Many
Demo 最容易这么写:
Sinks.Many<AgentRunEvent>sink=Sinks.many().multicast().onBackpressureBuffer();单机演示没问题。
但生产会遇到:
用户连到Pod A Agent事件产生在Pod B所以事件事实最好先进入数据库/Kafka/Redis Stream,再由所有 Web 节点消费。
一个简单的 Stream Service
@ServicepublicclassAgentRunEventStream{privatefinalEventRepositoryrepository;privatefinalLiveEventBusliveEventBus;publicFlux<AgentRunEvent>subscribe(StringrunId,StringlastEventId){Mono<Long>startSequence=repository.resolveSequence(runId,lastEventId).defaultIfEmpty(0L);returnstartSequence.flatMapMany(seq->{Flux<AgentRunEvent>history=repository.findAfter(runId,seq);Flux<AgentRunEvent>live=liveEventBus.events(runId);returnFlux.concat(history,live).distinct(AgentRunEvent::eventId);});}}重点是:
先补历史 再接实时还要用 Event ID 去重。
SSE 断线恢复
浏览器 EventSource 会自动重连。
服务端要让客户端能够告诉你:
我最后收到哪个EventSSE 原生有Last-Event-ID语义。
所以每个事件都设置:
.id(event.eventId())重连后只补缺失事件。
还需要 Heartbeat
如果几分钟没有 Agent 事件,代理或网关可能断开长连接。
可以每 15 秒发一个 heartbeat:
Flux<ServerSentEvent<AgentRunEvent>>heartbeat=Flux.interval(Duration.ofSeconds(15)).map(i->ServerSentEvent.<AgentRunEvent>builder().comment("heartbeat").build());与事件流合并。
前端安装 React Flow
npminstall@xyflow/react样式:
import '@xyflow/react/dist/style.css';如果使用 Tailwind 4 和最新 React Flow UI,样式组织方式可以按当前文档放到全局 CSS。
前端事件类型
exporttypeRunEvent={eventId:string;runId:string;sequence:number;type:string;stepId?:string;payload:Record<string,unknown>;occurredAt:string;};Projection 不要完全依赖后端每次传整棵图
后端可以传事件:
{"type":"STEP_COMPLETED","stepId":"inspect-build","sequence":18}前端 Reducer 应用增量。
functionreduceRun(state:RunView,event:RunEvent):RunView{switch(event.type){case'STEP_STARTED':returnupdateStep(state,event.stepId!,{status:'RUNNING'});case'STEP_COMPLETED':returnupdateStep(state,event.stepId!,{status:'DONE'});case'STEP_FAILED':returnupdateStep(state,event.stepId!,{status:'FAILED'});default:returnstate;}}React Flow 节点转换
importtype{Node,Edge}from'@xyflow/react';exportfunctiontoFlow(run:RunView):{nodes:Node[];edges:Edge[]}{constnodes:Node[]=run.steps.map((step,index)=>({id:step.stepId,position:{x:index*240,y:120,},data:{label:step.name,status:step.status,summary:step.summary,},type:'agentStep',}));constedges:Edge[]=[];for(conststepofrun.steps){for(constdepofstep.dependencies){edges.push({id:`${dep}-${step.stepId}`,source:dep,target:step.stepId,});}}return{nodes,edges};}自定义节点
function AgentStepNode({ data }: NodeProps) { return ( <div className={`step step-${data.status}`}> <div className="step-title"> {data.label} </div> <div className="step-status"> {data.status} </div> {data.summary && ( <div className="step-summary"> {data.summary} </div> )} </div> ); }不要只靠颜色表达状态。
同时显示文字:
PENDING RUNNING DONE FAILED BLOCKED WAITING_APPROVALEventSource Hook
浏览器原生 EventSource 不能方便地设置任意 Header。
如果认证依赖 Cookie,可以直接用:
useEffect(()=>{constsource=newEventSource(`/api/runs/${runId}/events`,{withCredentials:true});source.onmessage=(message)=>{constevent=JSON.parse(message.data);dispatch(event);};source.onerror=()=>{console.warn('run stream disconnected');};return()=>source.close();},[runId]);如果你使用 Bearer Token,通常需要 fetch streaming、同源 Session 或专门的 SSE Client,而不是把 Token 塞进 URL。
页面第一次加载怎么做
不要等 SSE 从头重放所有事件。
constinitial=awaitfetch(`/api/runs/${runId}`).then(r=>r.json());setRun(initial);这个接口返回当前 Snapshot。
然后 SSE 只接:
lastSequence之后的事件Snapshot + Event 是比较实用的组合
只有 Event:
一个运行三天的Agent 打开页面要重放几十万条事件只有 Snapshot:
不知道历史发生了什么组合:
Snapshot负责快速打开 Event负责历史与增量审批不要通过 SSE 回传
SSE 是服务端到客户端。
用户审批走普通接口:
POST /api/runs/{runId}/approvals/{approvalId}Body:
{"decision":"APPROVE","expectedRunVersion":29}服务端检查:
用户权限 Approval状态 Run Version Token 过期时间成功后产生:
APPROVAL_RESOLVEDSSE 再把新状态推回来。
右侧 Artifact 面板
不要把 Artifact 正文全部塞进 Node。
Node 只显示:
6 files changed点击后右侧打开:
pom.xml Dockerfile .github/workflows/build.ymlArtifact 后端模型:
publicrecordArtifactView(StringartifactId,Stringtype,Stringtitle,StringcontentHash,StringpreviewUrl,InstantcreatedAt){}Budget 也应该是一等公民
页面顶部:
Tokens 184K / 300K Cost $2.31 / $5.00 Model calls 14 / 25 Tool calls 28 / 50事件:
{"type":"BUDGET_UPDATED","payload":{"cost":2.31,"tokens":184302}}这样用户会第一次真正意识到:
Agent执行是有预算的前端不要显示完整 Chain-of-Thought
Run Canvas 需要展示的是:
- 步骤;
- 结果;
- 证据;
- Tool;
- Artifact;
- 错误;
- 决策。
不是模型内部私有推理。
例如:
Reason: 3个Auth测试失败,均与Refresh Token并发有关。就够了。
状态图和聊天怎么联动
用户在 Chat 里说:
先不要改Dockerfile。后端不是只把它追加成消息。
应该产生:
CONSTRAINT_ADDED PLAN_UPDATED STEP_CANCELLEDCanvas 立即看到 Plan 变化。
这才叫共享状态。
一个真正需要处理的坑:乱序事件
如果多 Worker 并发发布:
sequence 21 sequence 23 sequence 22前端不能按到达顺序应用。
最简单的办法是服务端保证 Run 内 Sequence 有序后再发。
否则前端要维护 Buffer:
if(event.sequence===lastSequence+1){apply(event);}else{buffer(event);refetchSnapshot();}我更倾向服务端处理。
第二个坑:页面开了几个小时
Run 可能已经归档。
服务端发送:
RUN_COMPLETED前端收到终态以后:
source.close();不要让数千个完成任务继续占着 SSE 连接。
第三个坑:Event Store 无限增长
保留策略可以是:
近期Run:完整事件 长期:压缩事件+关键Audit 事故Run:永久保留每 N 个事件写一个 Snapshot。
推荐的接口集合
POST /api/runs GET /api/runs/{runId} GET /api/runs/{runId}/events GET /api/runs/{runId}/artifacts POST /api/runs/{runId}/cancel POST /api/runs/{runId}/approvals/{id} POST /api/runs/{runId}/retry/{stepId}前端基本就够用了。
测试别只测页面能动
至少覆盖:
SSE断线重连 Last-Event-ID补发 重复事件去重 乱序 Snapshot与Event一致 多用户权限 审批版本冲突 Run完成关闭连接 Artifact越权 Budget实时更新我会怎么继续扩展这个 Demo
第一阶段先做:
Run Step SSE React Flow第二阶段加:
Artifact Approval Budget第三阶段再加:
Trace Diff Replay Multi-Agent不要第一天就做一个“AI IDE”。
最后
这套实现真正有价值的地方,不是 React Flow。
React Flow 只是把节点画出来。
真正的基础是:
结构化Run State +不可变Event +Snapshot +审批 +Artifact一旦这些后端语义存在,你可以把前端换成:
- React Flow;
- 看板;
- 时间线;
- 表格;
- IDE 插件。
都没关系。
Agent 产品真正该摆脱的是:
所有状态都藏在聊天字符串里当执行过程变成结构化状态以后,人才能真正看得懂、插得上手,也才敢让 Agent 跑更长的任务。