1. 项目概述:当一个前端工程师开始给 Agent 装上“刹车”和“记事本”
你有没有试过写完一个 LangGraph 流程,跑起来后就像放飞的风筝——它自己思考、自己调用工具、自己重试、自己决定下一步,直到某次 API 返回了个奇怪的 status code,或者 LLM 突然开始胡言乱语,整个流程直接卡死在某个节点,连日志都只显示agent execution terminated due to error.?我试过三次,每次都是从头 rerun 整个对话,重走一遍所有记忆、所有工具调用、所有中间状态。那感觉,就像开车没刹车、没后视镜、没仪表盘,全靠运气。
这就是标题里说的“全自动”陷阱。很多前端同学刚从 React/Vue 转向 Agent 开发时,第一反应是把useEffect换成graph.invoke(),把useState换成StateGraph,以为只要流程图画得漂亮,Agent 就能稳稳落地。但现实是:LangGraph 的默认执行模式(尤其是CompiledGraph)本质是个黑盒流水线,它不暴露中间态,不支持暂停,不记录每一步的输入输出,更不提供“回滚到上一步”的能力。一旦出错,你不是在 debug,是在考古。
而Agent Hooks 与 Checkpointer,就是给这个高速运转的智能体装上三样东西:方向盘(人为干预点)、行车记录仪(状态快照)、以及可随时踩下的刹车(执行暂停/恢复)。它们不是锦上添花的功能,而是把 Agent 从“自动贩卖机”升级为“可协作工作伙伴”的分水岭。尤其对前端出身的开发者——我们习惯于用户交互、状态管理、UI 可控性——这套机制天然契合我们的思维模型:Hook 是事件监听器,Checkpointer 是 localStorage + Redux DevTools 的合体,而整个流程,终于可以像调试一个 React 组件一样,逐帧 inspect、手动 step、甚至修改 state 后继续 run。
这个项目不教你如何写一个炫酷的 PI Agent 或 Hermes Agent,它聚焦在一个最朴素也最刚需的问题:当你的 Agent 在生产环境里跑着跑着突然“失联”,你怎么能在 30 秒内定位到是哪一次 tool call 失败?怎么能让产品经理说“刚才第三步生成的文案我不满意,换一种风格重来”,而你不用删掉前面所有历史重新 invoke?这就是 Hook 与 Checkpointer 的真实战场。它不解决“Agent 能不能思考”,它解决的是“你能不能信任它、指挥它、修复它”。
2. 核心设计思路:为什么必须用 Hook + Checkpointer,而不是“加个 console.log”?
很多人会想:既然出错了,加个 try/catch 不就行了?或者在每个 node 里console.log("entering node X", input)?这确实能看见一点信息,但离“可掌控”差了整整一个工程维度。我用一个真实场景对比说明:
2.1 单纯日志 vs. Hook + Checkpointer 的能力鸿沟
假设你正在开发一个“智能简历分析 Agent”,流程是:parse_pdf → extract_skills → match_jobs → generate_summary。某次运行中,match_jobs节点因为招聘平台 API 限流返回了 429,整个流程中断。
仅靠 console.log:你只能看到
"entering match_jobs"和"match_jobs failed: 429"。你不知道:parse_pdf输出的结构是否正确?(比如 PDF 解析漏掉了关键 section)extract_skills提取的技能列表里有没有拼写错误导致匹配失败?match_jobs的输入参数里,城市字段是不是传成了"ShangHai"而不是"Shanghai"?- 更重要的是:你无法让 Agent “回到
extract_skills后,用另一个技能提取模型重跑,再进match_jobs”。
启用 Checkpointer + Hook 后:你可以:
- 查看
checkpointer.get_state(config),拿到match_jobs节点失败前的完整 state,包括pdf_text,skills_list,user_location等所有字段; - 手动修改
state.values.skills_list,把"Reactjs"改成"React.js"; - 调用
graph.update_state(config, {"skills_list": ["React.js", "TypeScript"]}),将修正后的 state 注入; - 再次
graph.invoke(..., config=config),Agent 会从match_jobs节点继续执行,跳过前面所有已成功步骤。
- 查看
这背后不是魔法,而是 LangGraph 对“状态”和“生命周期”的彻底重构。它把 Agent 的每一次执行,从一次性的函数调用,变成了一个带版本号、可寻址、可编辑的持久化对象。而 Hook,则是这个对象的“神经末梢”,让你能在任何关键节点插入自己的逻辑——比如在generate_summary前弹出一个 UI 确认框(前端场景),或在tool_call后自动记录耗时并告警(运维场景)。
2.2 为什么前端开发者特别需要这套机制?
前端同学转 Agent 开发,最大的认知迁移不是学 Python,而是从“声明式 UI”转向“状态驱动流程”。React 的useState让你随时setState({ loading: false, data: result });Vue 的ref让你myRef.value = newValue。但 LangGraph 默认的graph.invoke()是“命令式”的——你发一个指令,它跑完给你一个结果,中间过程你无法介入。
Hook 和 Checkpointer,本质上就是把前端最熟悉的“响应式状态管理”范式,移植到了 Agent 编排层:
on_chain_start/on_chain_endHook,就像useEffect(() => { ... }, [deps]),监听节点生命周期;Checkpointer就是Redux store+localStorage,get_state是store.getState(),update_state是store.dispatch({ type: 'UPDATE', payload });config(包含thread_id)就是key,确保每个用户会话的状态独立隔离,就像 React 的key属性保证列表项唯一性。
所以,这不是“Python 工程师该学的东西”,这是“前端工程师在构建下一代人机协作界面时,必须掌握的底层协议”。当你用useAgentState(threadId)hook 在 React 中订阅 Agent 状态变化,并在 UI 上实时显示currentStep: "matching jobs..."、progress: 65%、lastError: "API rate limit exceeded"时,你写的就不是一个脚本,而是一个真正意义上的“智能应用”。
2.3 LangChain 与 LangGraph 的关键分野:编排权的归属
网上常有人问“LangChain 和 LangGraph 有什么区别”,答案不能只停留在 API 差异。核心在于:LangChain 的AgentExecutor把编排逻辑藏在框架内部,你只能配置max_iterations或handle_parsing_errors;而 LangGraph 把编排权完全交还给开发者,通过StateGraph显式定义每一步,再通过Checkpointer和Hooks让你拥有对每一步的绝对控制权。
举个例子:LangChain 的OpenAIFunctionsAgent,你无法在tool_call和tool_response之间插入自定义逻辑(比如做参数校验、加缓存、改 response 格式)。它要么成功,要么失败重试,没有中间态。而 LangGraph 中,你可以:
def tool_node(state: State): # 这里就是你的“中间态” tool_calls = state["messages"][-1].tool_calls # ✅ Hook 点:在此处校验 tool_calls 参数合法性 if not validate_tool_params(tool_calls): return {"messages": [AIMessage(content="参数错误,请检查输入")]} # ✅ Checkpoint 点:记录本次 tool_call 的原始请求 checkpointer.save_checkpoint( config=state["config"], checkpoint={"tool_request": tool_calls}, step=state["step"] ) # 执行调用... response = execute_tool(tool_calls) return {"messages": [ToolMessage(content=str(response), tool_call_id=...)]}这种显式、可控、可插拔的设计,正是前端工程师所珍视的“透明性”。它意味着你不再需要祈祷框架的默认行为符合你的业务逻辑,而是可以像写组件一样,精准地在每一个环节注入自己的判断和处理。
3. 核心细节解析:Agent Hooks 的类型、触发时机与实战用法
LangGraph 的 Hooks 体系,远比useEffect的mount/update/unmount三阶段复杂。它是一套覆盖整个 Agent 生命周期的“事件总线”,每个事件都有明确的触发条件、携带的上下文数据,以及严格的执行顺序。理解这些,是写出健壮、可维护 Hook 的前提。
3.1 六大核心 Hook 类型及其不可替代的用途
LangGraph 定义了BaseCallbackHandler接口,但实际使用中,我们主要关注以下六类 Hook,它们按执行顺序排列,构成了 Agent 的“神经脉冲”:
| Hook 名称 | 触发时机 | 关键参数 | 前端类比 | 典型用途 |
|---|---|---|---|---|
on_chain_start | 任意节点(node)开始执行前 | run_id,name,inputs | componentWillMount | 日志记录、性能计时开始、权限校验(如检查用户是否有权调用此工具) |
on_chain_end | 任意节点执行成功后 | run_id,outputs,name | componentDidUpdate | 结果后处理(如格式化 JSON)、状态聚合、发送通知(邮件/SMS) |
on_chain_error | 任意节点执行抛出异常时 | run_id,error,name | componentDidCatch | 错误分类、降级策略(如 fallback 到规则引擎)、告警推送 |
on_tool_start | Tool 调用发起前(即tool_node开始) | run_id,name,tool_input | beforeRequest(Axios interceptor) | 参数脱敏(隐藏 API key)、请求签名、缓存查询(查本地 DB 是否有相同请求的缓存) |
on_tool_end | Tool 调用返回后(即tool_node结束) | run_id,output,name | afterResponse(Axios interceptor) | 响应校验(检查 HTTP status)、结果缓存写入、耗时统计(end_time - start_time) |
on_event | (高级)自定义事件,需在 graph 中显式yield {"event": "xxx"} | event,data,metadata | dispatchEvent(new CustomEvent('xxx')) | 跨节点通信(如parse_pdf成功后触发notify_frontend事件) |
提示:
on_event是最容易被忽略但威力最大的 Hook。它打破了节点间的硬耦合。例如,你不需要在parse_pdf节点里写if success: send_to_frontend(),而是yield {"event": "pdf_parsed", "data": {"text": extracted_text}},然后在 Hook 中统一处理所有pdf_parsed事件——这正是前端 Event Bus 思维的完美复刻。
3.2 实战:一个“前端友好”的 Hook 集成方案
作为前端开发者,我们最关心的不是“如何注册 Hook”,而是“如何让 Hook 的输出,能被我的 React 组件消费”。这里给出一个经过生产验证的方案:
Step 1:创建一个全局事件总线(EventBus)
# hooks/event_bus.py from typing import Dict, List, Callable, Any import threading class EventBus: def __init__(self): self._handlers: Dict[str, List[Callable]] = {} self._lock = threading.Lock() def on(self, event: str, handler: Callable): with self._lock: if event not in self._handlers: self._handlers[event] = [] self._handlers[event].append(handler) def emit(self, event: str, data: Any = None): with self._lock: handlers = self._handlers.get(event, []) for handler in handlers: try: handler(data) except Exception as e: print(f"Error in event handler {event}: {e}") # 全局实例 event_bus = EventBus()Step 2:编写一个专为前端服务的 Hook
# hooks/frontend_hook.py from langgraph.callbacks.base import BaseCallbackHandler from hooks.event_bus import event_bus class FrontendHook(BaseCallbackHandler): def on_chain_start(self, run_id: str, name: str, inputs: dict, **kwargs): # 发送节点启动事件,供前端 UI 显示加载状态 event_bus.emit("node_start", { "run_id": run_id, "node": name, "timestamp": time.time(), "inputs": {k: str(v)[:100] for k, v in inputs.items()} # 截断防过大 }) def on_chain_end(self, run_id: str, outputs: dict, name: str, **kwargs): # 发送节点结束事件,含结果摘要 event_bus.emit("node_end", { "run_id": run_id, "node": name, "timestamp": time.time(), "outputs": {k: str(v)[:200] for k, v in outputs.items()} }) def on_chain_error(self, run_id: str, error: Exception, name: str, **kwargs): # 发送错误事件,含可读错误码 error_code = "UNKNOWN" if "429" in str(error): error_code = "RATE_LIMIT_EXCEEDED" elif "timeout" in str(error).lower(): error_code = "REQUEST_TIMEOUT" event_bus.emit("node_error", { "run_id": run_id, "node": name, "error_code": error_code, "message": str(error)[:500] })Step 3:在 FastAPI 后端暴露 WebSocket 接口(供前端连接)
# api/ws.py from fastapi import WebSocket, WebSocketDisconnect from hooks.event_bus import event_bus active_connections: List[WebSocket] = [] @router.websocket("/ws/agent-state") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() active_connections.append(websocket) # 注册事件处理器 def send_to_ws(data): for conn in active_connections: try: await conn.send_json(data) except Exception: pass event_bus.on("node_start", send_to_ws) event_bus.on("node_end", send_to_ws) event_bus.on("node_error", send_to_ws) try: while True: # 心跳保活 await websocket.receive_text() except WebSocketDisconnect: active_connections.remove(websocket) # 清理事件监听器(简化版,实际需更严谨) event_bus._handlers.pop("node_start", None) event_bus._handlers.pop("node_end", None) event_bus._handlers.pop("node_error", None)Step 4:前端 React 组件订阅状态
// components/AgentStatus.tsx import { useEffect, useState } from 'react'; import { io } from 'socket.io-client'; const AgentStatus = () => { const [status, setStatus] = useState<{ currentStep: string; progress: number; lastError?: string }>({ currentStep: 'idle', progress: 0 }); useEffect(() => { const socket = io('http://localhost:8000/ws/agent-state'); socket.on('node_start', (data: any) => { setStatus(prev => ({ ...prev, currentStep: data.node, progress: prev.progress + 10 })); }); socket.on('node_end', (data: any) => { if (data.node === 'generate_summary') { // 最终结果到达,可更新 UI setSummary(data.outputs.summary); } }); socket.on('node_error', (data: any) => { setStatus(prev => ({ ...prev, lastError: data.message })); // 弹出错误提示框 showNotification(`步骤 ${data.node} 失败: ${data.error_code}`); }); return () => { socket.close(); }; }, []); return ( <div className="agent-status"> <div>当前步骤: {status.currentStep}</div> <div>进度: {status.progress}%</div> {status.lastError && <div className="error">⚠️ {status.lastError}</div>} </div> ); }; export default AgentStatus;这个方案的价值在于:它把 LangGraph 的底层事件,无缝映射到了前端的响应式 UI 生态中。你不再需要轮询/api/agent/status,也不需要在每个节点里手动fetch('/api/update-ui')。Hook 是被动的监听者,EventBus 是消息中枢,WebSocket 是管道,React 是最终的消费者——整条链路清晰、解耦、可测试。
3.3 注意事项:Hook 使用的三大陷阱
陷阱一:Hook 中的异步操作未 await,导致状态错乱
很多人在on_chain_end里写await save_to_db(outputs),却忘了on_chain_end本身是同步回调。LangGraph 不会等待你的await完成。正确做法:在 Hook 中启动一个后台任务(如asyncio.create_task(save_to_db(...))),或使用线程池(concurrent.futures.ThreadPoolExecutor)。否则,下一个节点可能在数据库写入完成前就开始执行,读到脏数据。陷阱二:过度依赖
on_tool_end,忽视on_chain_end的粒度on_tool_end只捕获工具调用,而on_chain_end捕获所有节点(包括llm_node,tool_node,conditional_node)。如果你只想记录“LLM 生成了什么”,用on_chain_end并过滤name == "llm";如果只想记录“调用了哪个 API”,才用on_tool_end。混用会导致日志重复或遗漏。陷阱三:在 Hook 中修改
state,期望影响后续节点
Hook 是观察者,不是参与者。你在 Hook 里修改inputs或outputs字典,不会改变实际流入/流出节点的数据。LangGraph 的 state 是 immutable 的(通过StateGraph的add_node和add_edge定义),Hook 只能“看”,不能“改”。要修改 state,必须在节点函数内部return {"key": new_value},或用graph.update_state()。
4. Checkpointer 深度实现:从内存快照到分布式持久化
如果说 Hook 是 Agent 的“感官系统”,那么 Checkpointer 就是它的“记忆中枢”。没有 Checkpointer,Agent 就像金鱼,只有 7 秒记忆;有了它,Agent 才能成为真正的“长期伙伴”。但 Checkpointer 的配置,绝不是checkpointer = MemorySaver()一行代码那么简单。
4.1 Checkpointer 的三种核心模式与选型逻辑
LangGraph 提供了多种 Checkpointer 实现,选择哪种,取决于你的应用场景、数据规模和可靠性要求:
| Checkpointer 类型 | 存储位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
MemorySaver | 进程内存 | 启动快、零配置、调试友好 | 进程重启即丢失、不支持多实例、无持久化 | 本地开发、单元测试、POC 验证 |
SqliteSaver | 本地 SQLite 文件 | 持久化、支持多会话、轻量级 | 单机、不支持高并发写入、无备份机制 | 小型内部工具、桌面应用(如 Windows Hermes Agent 桌面版)、单机部署的 MVP |
PostgresSaver | PostgreSQL 数据库 | 强一致性、高并发、支持备份/HA、成熟生态 | 需额外 DB 运维、网络延迟、配置复杂 | 生产环境、SaaS 应用、需要审计日志的金融/医疗场景 |
提示:不要被
MemorySaver的简单迷惑。我在一个客户项目中,用它跑了两周,直到某次服务器重启,所有用户的历史对话全部消失,客户投诉电话打爆。MemorySaver只应出现在if DEBUG:分支里,永远不要在 production 环境的requirements.txt中出现。这是血泪教训。
4.2 从零搭建 PostgresSaver:不只是填个连接字符串
PostgresSaver的配置远不止PostgresSaver(connection_string="...")。以下是生产环境必须配置的 5 个关键参数,缺一不可:
from langgraph.checkpoint.postgres import PostgresSaver import asyncpg # 1. 连接池配置(避免连接耗尽) async def create_pool(): return await asyncpg.create_pool( host="db.example.com", port=5432, user="langgraph_user", password="your_strong_password", database="langgraph_db", min_size=5, # 最小连接数,避免冷启动延迟 max_size=20, # 最大连接数,防止 DB 过载 command_timeout=30, # 查询超时,防止慢 SQL 拖垮整个服务 ) # 2. 表名前缀(多租户隔离) saver = PostgresSaver( pool=await create_pool(), table_name="langgraph_checkpoints", # 默认是 checkpoints,建议加前缀 ) # 3. 自定义序列化器(处理非 JSON-serializable 类型) import pickle from langgraph.checkpoint import BaseCheckpointSaver class PicklePostgresSaver(PostgresSaver): def serialize(self, obj: Any) -> bytes: # 对于 numpy array, datetime 等,用 pickle return pickle.dumps(obj) def deserialize(self, data: bytes) -> Any: return pickle.loads(data) # 4. TTL(Time-To-Live)自动清理(防止磁盘爆满) # 在数据库中为 checkpoints 表添加 created_at 字段,并定期执行: # DELETE FROM langgraph_checkpoints WHERE created_at < NOW() - INTERVAL '30 days'; # 5. 加密存储(敏感字段如 API keys) # 在 save_checkpoint 前,对 state.values 中的敏感字段 AES 加密 # 在 get_state 后,对敏感字段 AES 解密 # (注意:加密密钥必须安全存储,如 HashiCorp Vault)4.3 实操:一个可恢复的“简历重写”工作流
让我们用一个具体案例,展示 Checkpointer 如何让 Agent 真正“可掌控”。需求:用户上传一份简历 PDF,Agent 分析后生成三版不同风格的 Summary(专业版、活泼版、极简版),用户可任选其一,或要求重写某版。
Step 1:定义 State(关键!State 结构决定 Checkpoint 的粒度)
from typing import Annotated, Sequence, TypedDict, Optional import operator class State(TypedDict): pdf_bytes: bytes # 原始 PDF,只存一次 parsed_text: str # 解析后的文本 skills: list[str] # 提取的技能 summary_options: dict[str, str] # 三个版本的 summary,key: "professional", "casual", "minimal" selected_summary: Optional[str] # 用户最终选择 revision_request: Optional[str] # 用户要求重写的版本名,如 "casual" thread_id: str # 用于 Checkpointer 的 configStep 2:关键节点——支持“重写”的rewrite_summary
def rewrite_summary(state: State) -> State: # 1. 从 Checkpointer 获取当前 state(确保是最新的) # (实际中,state 已由 LangGraph 自动注入,此处为说明逻辑) # 2. 判断是首次生成,还是重写 if state.get("revision_request"): # 重写指定版本 target_style = state["revision_request"] # 用 LLM 重写,提示词强调“保持原意,只改变风格” new_summary = llm.invoke( f"Rewrite this summary in {target_style} style:\n{state['summary_options'][target_style]}" ) # 更新对应版本 state["summary_options"][target_style] = new_summary.content # 清空 revision_request,表示本次重写完成 state["revision_request"] = None else: # 首次生成,三版并行 state["summary_options"] = { "professional": generate_professional(state["parsed_text"]), "casual": generate_casual(state["parsed_text"]), "minimal": generate_minimal(state["parsed_text"]) } return stateStep 3:前端交互逻辑(React)
// 当用户点击“重写活泼版”按钮时 const handleRewriteCasual = async () => { // 1. 调用后端 API,设置 revision_request await fetch('/api/agent/revision', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: currentThreadId, revision_request: 'casual' }) }); // 2. 后端收到请求后,执行: // graph.update_state( // config={"configurable": {"thread_id": thread_id}}, // values={"revision_request": "casual"} // ) // 然后调用 graph.invoke(...),Agent 会从 rewrite_summary 节点继续执行 // 3. 前端监听 WebSocket 事件,等待新 summary 到达 eventBus.on('summary_updated', (data) => { if (data.style === 'casual') { setCasualSummary(data.content); setIsRewriting(false); } }); };Step 4:Checkpointer 的“救命”时刻假设rewrite_summary节点在调用 LLM 时,因网络抖动超时失败。此时:
on_chain_errorHook 会被触发,记录错误;- Checkpointer 已保存了失败前的 state(含
revision_request: "casual"); - 用户刷新页面,前端用
thread_id重新连接; - 后端
graph.get_state(config)拿到 state,发现revision_request仍为"casual",知道这是个未完成的重写任务; - 无需用户任何操作,Agent 自动重试
rewrite_summary,且只重试这一步,前面的parsed_text和skills完全复用。
这就是 Checkpointer 赋予的“韧性”。它让 Agent 不再是一次性的函数,而是一个有记忆、有状态、可中断、可恢复的长生命周期服务。
4.4 常见问题排查:Checkpointer 的 5 个高频故障点
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
get_state返回None | thread_id错误,或该 thread_id 下无 checkpoint | SELECT * FROM langgraph_checkpoints WHERE thread_id = 'xxx'; | 检查前端传入的thread_id是否与graph.invoke(..., config={"configurable": {"thread_id": "xxx"}})一致;确认save_checkpoint是否被调用(加日志) |
update_state后get_state仍是旧值 | update_state的config与get_state的config不一致(如thread_id相同但checkpoint_id不同) | SELECT checkpoint_id, parent_checkpoint_id FROM langgraph_checkpoints WHERE thread_id = 'xxx' ORDER BY timestamp DESC LIMIT 5; | update_state会创建新 checkpoint,get_state默认获取最新 checkpoint。若需获取特定版本,用get_state(config, checkpoint_id="xxx") |
| PostgreSQL CPU 100%,查询变慢 | checkpoints表数据量过大,缺乏索引 | \d langgraph_checkpoints查看索引;EXPLAIN ANALYZE SELECT * FROM langgraph_checkpoints WHERE thread_id = 'xxx' ORDER BY timestamp DESC LIMIT 1; | 为thread_id和timestamp字段创建复合索引:CREATE INDEX idx_thread_ts ON langgraph_checkpoints (thread_id, timestamp DESC); |
| 多个用户会话状态互相污染 | thread_id生成逻辑有 bug,导致不同用户共用同一thread_id | 在on_chain_startHook 中打印run_id和config["configurable"]["thread_id"] | thread_id必须全局唯一,推荐用uuid.uuid4().hex,绝对不要用用户 ID(如user_123)作为thread_id,因为一个用户可能有多个并发会话 |
MemorySaver在 FastAPI 重启后丢失状态 | MemorySaver实例被重新创建 | print(id(saver))在每次请求中查看 saver 实例地址 | 将MemorySaver实例作为 FastAPI 的app.state.saver,在startup事件中初始化一次,而非每次请求都新建 |
5. 前端集成实战:用 React 构建一个“可掌控”的 Agent 控制台
现在,我们把前面所有的技术点,整合成一个真实的前端界面。这不是一个 demo,而是一个可直接用于生产环境的“Agent 控制台”最小可行产品(MVP)。
5.1 核心功能模块设计
一个真正“可掌控”的控制台,必须包含以下四个核心区域:
- 状态概览区(Top Bar):显示
thread_id、当前step、整体progress、last_error(如果有); - 流程图可视化区(Center):动态渲染 LangGraph 的节点和边,高亮当前执行节点,灰色化已完成节点,红色标出失败节点;
- 状态检查器(Right Panel):以树形结构展示当前
state的所有 key-value,支持展开/折叠、搜索、编辑(update_state); - 操作控制台(Bottom Console):提供
Resume(从失败点继续)、Rerun from here(从当前节点重跑)、Edit & Continue(修改 state 后继续)等按钮。
5.2 实现StateInspector组件:让 state 可读、可查、可改
这是控制台的灵魂。它必须能处理任意嵌套的dict/list/str/int,并安全地调用update_state。
// components/StateInspector.tsx import { useState, useEffect, useCallback } from 'react'; import { useAgentState } from '../hooks/useAgentState'; // 自定义 hook,封装了 WebSocket 连接和 state 同步 import { updateAgentState } from '../api/agentApi'; // 封装了 POST /api/agent/update-state interface StateNodeProps { path: string[]; value: any; depth: number; onEdit: (path: string[], newValue: any) => void; } const StateNode = ({ path, value, depth, onEdit }: StateNodeProps) => { const [expanded, setExpanded] = useState(true); const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(JSON.stringify(value, null, 2)); const isObject = typeof value === 'object' && value !== null && !Array.isArray(value); const isArray = Array.isArray(value); const handleEditSubmit = () => { try { const parsed = JSON.parse(editValue); onEdit(path, parsed); setEditing(false); } catch (e) { alert('JSON 格式错误'); } }; return ( <div style={{ marginLeft: depth * 20 }}> <div> <span style={{ color: '#666' }}>{path.join('.')}: </span> {editing ? ( <div> <textarea value={editValue} onChange={(e) => setEditValue(e.target.value)} rows={3} style={{ width: '100%', fontFamily: 'monospace' }} /> <button onClick={handleEditSubmit}>✓ Save</button> <button onClick={() => setEditing(false)}>✗ Cancel</button> </div> ) : ( <span> {isObject || isArray ? ( <span> <button onClick={() => setExpanded(!expanded)}> {expanded ? '▼' : '▶'} {isObject ? 'Object' : 'Array'} ({Object.keys(value).length}) </button> {expanded && ( <div> {isObject && Object.entries(value).map(([k, v]) => ( <StateNode key={k} path={[...path, k]} value={v} depth={depth + 1} onEdit={onEdit} /> ))} {isArray && value.map((item: any, i: number) => ( <StateNode key={i} path={[...path, String(i)]} value={item} depth={depth + 1} onEdit={onEdit} /> ))} </div> )} </span> ) : ( <span> <span style={{ color: '#28a745' }}>{typeof value === 'string' ? `"${value}"` : String(value)}</span> <button onClick={() => setEditing(true)} style={{ marginLeft: '8px' }}>✏️ Edit</button> </span> )} </span> )} </div> </div> ); }; const StateInspector = ({ threadId }: { threadId: string }) => { const { state, loading } = useAgentState(threadId); const [searchTerm, setSearchTerm] = useState(''); const filteredState = useMemo(() => { if (!searchTerm || !state) return state; // 简单的关键词搜索(实际可用更强大的 jsonpath) const search = searchTerm.toLowerCase(); const filter = (obj: any, path: string[] = []): any => { if (typeof obj === 'object' && obj !== null) { if (Array.isArray(obj)) { return obj.map((item, i) => filter(item, [...path, String(i)])); } else { const filtered: any = {}; for (const [k, v] of Object.entries(obj)) { const fullPath = [...path, k].join('.');