news 2026/9/13 14:24:47

CopilotKit × LangGraph TypeScript:用共享状态实现 UI 与 Agent 双向读写的实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CopilotKit × LangGraph TypeScript:用共享状态实现 UI 与 Agent 双向读写的实战解析

CopilotKit × LangGraph TypeScript:用共享状态实现 UI 与 Agent 双向读写的实战解析

【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit

本文以 CopilotKit 仓库中showcase/integrations/langgraph-typescript下的 Shared State (Read + Write) 示例为核心,完整拆解 UI 与 Agent 之间的双向共享状态模式:前端如何把用户偏好写进 Agent 状态并影响模型回复,Agent 又如何通过set_notes工具把“记忆”写回 UI 并实时触发重渲染。读完本文,你将掌握useAgent+agent.setState+ LangGraphCommand这套端到端双向状态同步的完整链路,并能独立复刻类似的“表单驱动 Agent 行为”应用。

示例定位:同一个状态对象,两边都能读和写

该示例(README)演示的是UI 与 Agent 之间的双向共享状态——两侧都读、都写同一个状态对象,具体包含三条主线:

  • UI → Agent(写):页面侧栏表单(name / tone / language / interests)通过agent.setState(...)把数据写入state.preferences;后端每一轮对话都会读取它并注入系统提示词(system prompt)。
  • Agent → UI(写):Agent 的set_notes工具把内容写入state.notes;侧栏的笔记卡片在 Agent 每次更新后自动重新渲染。
  • 往返闭环(Round-trip):在侧栏修改偏好后,Agent 的下一条回复会肉眼可见地随之改变——语气(tone)、语言(language)、用名字称呼用户。

如何与示例交互

先编辑侧栏偏好,然后依次尝试以下提示词(它们也对应页面里由 suggestions.ts 注入的三枚建议按钮):

  • "Say hi and introduce yourself."
  • "Remember that I prefer morning meetings and that I don't eat dairy."
  • "Suggest a weekend plan based on my interests."

观察要点:Agent 的回复会随偏好变化而改变;当你让它“记住”某些事时,侧栏笔记卡会实时出现新条目。

前端:状态形状、订阅与写入

状态形状:preferences由 UI 写,notes由 Agent 写

入口页面 page.tsx 定义了双向状态的整体形状:

// 双向共享状态的形状: // - `preferences` 由 UI 通过 agent.setState() 写入 // - `notes` 由 Agent 通过其 `set_notes` 工具写入、 // 由 UI 通过 useAgent() 读取 interface RWAgentState { preferences: Preferences; notes: string[]; }

其中Preferences在 preferences-card.tsx 中定义,字段为:

export interface Preferences { name: string; tone: "formal" | "casual" | "playful"; language: string; interests: string[]; }

页面通过<CopilotKit runtimeUrl="/api/copilotkit" agent="shared-state-read-write">把运行时地址和 Agent ID 绑定到整个页面。

读侧:useAgent订阅状态变更

const { agent } = useAgent({ agentId: "shared-state-read-write", updates: [UseAgentUpdate.OnStateChanged], }); const agentState = agent.state as RWAgentState | undefined; const preferences = agentState?.preferences ?? INITIAL_PREFERENCES; const notes = agentState?.notes ?? [];

updates: [UseAgentUpdate.OnStateChanged]让组件订阅 Agent 的每一次状态变更:只要 Agent 侧(例如set_notes工具)修改了state.notes,该 hook 就会触发重渲染,侧栏笔记卡随之刷新——这就是 Agent → UI 方向的“读”的实现。

另外页面用useEffect在首次挂载时做了一次状态播种(seed):若agentState.preferences尚不存在,则调用agent.setState({ preferences: INITIAL_PREFERENCES, notes: [] }),保证 Agent 在第一轮就有可读取的偏好(tone: "casual"language: "English"、空的 name 与 interests)。

写侧:所有编辑都流经agent.setState

侧栏表单的每一次变更都由 demo-layout.tsx 中的PreferencesCard(受控表单)上抛onChange,最终在页面层收敛为同一个调用:

const handlePreferencesChange = (next: Preferences) => { agent.setState({ preferences: next, notes, // 保留 Agent 已写入的笔记 } as RWAgentState); }; // UI 反向清空 Agent 写的笔记 const handleClearNotes = () => { agent.setState({ preferences, notes: [] } as RWAgentState); };

两个关键细节值得注意:

  1. setState是全量替换语义:写入preferences时必须把当前的notes一并带上,否则会覆盖 Agent 之前写入的笔记——这也是handlePreferencesChange里显式传notes的原因。
  2. 同一字段双向可写notes既由 Agent 的set_notes工具写入,也能被 UI 的 “Clear” 按钮通过agent.setState({ notes: [] })清空。QA 清单(qa/shared-state-read-write.md)中专门验证了这一点:清空后再问 "What do you remember about me?",Agent 不应再引用被清掉的笔记,因为状态已被 UI 回写。

组件分层:表单组件不感知 Agent

从源码结构看,PreferencesCardNotesCard(notes-card.tsx)都是“纯组件”:前者只接收value/onChange,后者只接收notes/onClear,自身从不触碰 Agent 状态;所有与 Agent 的接线都上移一层到页面组件中。这种分层让卡片可以被独立测试和复用。此外PreferencesCard底部用<pre>实时打印当前 preferences 的 JSON(data-testid="pref-state-json"),让“UI 到底写了什么进状态”变得可见、可断言——QA 脚本正是依赖这一预览做校验的。

后端:LangGraph 图中的注入、工具与路由

状态注解:CopilotKitStateAnnotation之上扩展业务槽位

Agent 实现位于 shared-state-read-write.ts。共享状态通过 LangGraph 的Annotation.Root声明,并在 CopilotKit 的基础槽位之上叠加两个业务字段:

const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec, // messages / copilotkit 等基础槽位 preferences: Annotation<Preferences | undefined>, notes: Annotation<string[]>, });

CopilotKitStateAnnotation来自@copilotkit/sdk-js/langgraph,它带来了消息通道与copilotkit动作槽位;preferencesnotes则是本示例自定义的双向共享通道。

UI 写入如何被模型“看见”:偏好注入

README 中提到后端有PreferencesInjectorMiddleware.wrap_model_call(该写法对应 Python 版 shared_state_read_write 的中间件实现)。在 TypeScript 版中,等价逻辑落在 chat node 内:每一轮都从状态里读出最新preferences,构造一条SystemMessage前置到消息列表:

function buildPreferencesMessage(prefs: Preferences | undefined): SystemMessage | null { if (!prefs) return null; const lines: string[] = []; if (prefs.name) lines.push(`- Name: ${prefs.name}`); if (prefs.tone) lines.push(`- Preferred tone: ${prefs.tone}`); if (prefs.language) lines.push(`- Preferred language: ${prefs.language}`); if (prefs.interests && prefs.interests.length > 0) { lines.push(`- Interests: ${prefs.interests.join(", ")}`); } if (lines.length === 0) return null; // 空偏好时跳过注入 return new SystemMessage({ content: [ "The user has shared these preferences with you:", ...lines, "Tailor every response to these preferences. Address the user by " + "name when appropriate.", ].join("\n"), }); }

chatNode中,BASE_SYSTEM_PROMPT(要求模型尊重偏好、在用户要求“记住”时调用set_notes)与偏好消息一起前置:

const systemMessages = prefsMessage ? [baseSystem, prefsMessage] : [baseSystem]; const response = await modelWithTools.invoke([...systemMessages, ...state.messages], config);

模型侧参数为temperature: 0gpt-4o-mini,并关闭parallel_tool_calls(见makeChatOpenAI调用处)。由于注入发生在每一轮,UI 的写入无需重发即可持续影响后续回复——QA 清单中“把 tone 改为 playful 后连续两轮追问”的测试正是验证这种跨轮持久性。

Agent 写入:set_notes工具用Command双路回写

Agent → 共享状态的写路径由set_notes工具承担,核心是返回一个Command同时完成两件事:向notes通道写入新值(UI 会因此重渲染),以及产出一条ToolMessage(让 LLM 在下一轮看到格式合法的工具结果):

const setNotes = tool( async ({ notes }, config: ToolRunnableConfig) => { const toolCallId = config.toolCall?.id; if (typeof toolCallId !== "string" || toolCallId.length === 0) { throw new Error("set_notes: missing tool_call_id — ..."); } return new Command({ update: { notes, messages: [ new ToolMessage({ status: "success", name: "set_notes", tool_call_id: toolCallId, content: "Notes updated.", }), ], }, }); }, { name: "set_notes", description: "Replace the notes array in shared state with the full updated list. ... " + "Always pass the FULL notes list (existing notes + any new ones), not a diff.", schema: z.object({ notes: z.array(z.string()).describe("The full updated notes list (replaces previous value)."), }), }, );

这里有三个工程要点:

  1. 全量替换契约:工具描述明确要求传“完整的新列表(现有 + 新增)”而不是增量,且每条笔记建议小于 120 字符。QA 流程验证了这一点:先记住两条,再补一条 "Also remember I live in Berlin.",笔记列表应只增不丢。
  2. tool_call_id校验:若工具脱离ToolNode上下文被调用(拿不到 tool call id),直接抛错拒绝发射空tool_call_idToolMessage,因为 OpenAI 会拒绝这类消息——这是对 LLM API 格式约束的显式防御。
  3. Command.update一次写两个通道notes(共享状态)与messages(对话历史),保证 UI 视图与模型上下文一致。

路由与图编译:区分前端动作与后端工具

shouldContinue负责条件路由:若最后一条 AIMessage 携带的tool_calls中有不属于CopilotKit 前端动作(state.copilotkit.actions)的调用,则路由到tool_node,否则结束本轮:

const hasBackendToolCall = lastMessage.tool_calls.some((toolCall) => !actions || actions.every((action) => action.name !== toolCall.name) );

即前端动作交由 CopilotKit 协议处理,后端工具(set_notes)留在图内执行。图结构为经典的 chat ↔ tool 循环:

const workflow = new StateGraph(AgentStateAnnotation) .addNode("chat_node", chatNode) .addNode("tool_node", new ToolNode(tools)) .addEdge(START, "chat_node") .addEdge("tool_node", "chat_node") .addConditionalEdges("chat_node", shouldContinue as any); const graph = workflow.compile({ checkpointer: new MemorySaver() });

MemorySavercheckpointer 提供会话内状态持久化,这正是notes跨轮保留的前提。

端到端闭环与注册链路

把两侧串起来,一条 “Remember that I prefer morning meetings..." 消息的完整链路是:

  1. UI 侧栏此前已通过agent.setState({ preferences, notes })把偏好写入共享状态;
  2. 消息进入shared-state-read-write图,chatNodestate.preferences构建偏好SystemMessage前置后调用模型;
  3. 模型决定调用set_notes并给出完整笔记列表,路由到tool_node执行;
  4. Command.update同时写入notes通道与messages通道;
  5. 状态变更经 CopilotKit 运行时推回前端,useAgent({ updates: [OnStateChanged] })触发重渲染,笔记卡出现新条目。

该图在 langgraph.json 中注册为"shared_state_read_write": "./shared-state-read-write.ts:graph";前端 Agent 名到图名的映射("shared-state-read-write" -> "shared_state_read_write")则在 route.ts 中完成,两者命名不同(前端用短横线、后端用下划线)但必须一一对应,否则 Agent 无法命中图。

运行前提与验证方式

  • 运行时依赖:LangGraph 部署需暴露shared_state_read_write图,部署配置见 langgraph.json(Node 20,环境变量取自项目.env,需配置OPENAI_API_KEY);
  • 页面入口为/demos/shared-state-read-write,依赖/api/copilotkit代理与健康的 Agent 后端(/api/health);
  • 完整的行为验证清单见 qa/shared-state-read-write.md,它按“UI 写 → Agent 读”“Agent 写 → UI 读”“UI 回写 Agent 槽位”“多轮持久化”“错误处理”五个维度覆盖了本示例的全部关键路径(含 testid 断言与响应时限),可作为复现该模式时的验收模板。

小结

本示例展示了双向共享状态的最小完备形态:Annotation声明共享槽位、agent.setState承担 UI 写入(注意全量替换语义)、Command(update)承担 Agent 侧“写状态 + 回消息”的双路更新、useAgent({ updates: [OnStateChanged] })承担读侧订阅、每轮注入系统消息让 UI 的写入持续影响模型。理解这条链路后,可以将其推广到任何需要“表单/画布驱动 Agent,且 Agent 能回写界面”的场景。

【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 14:22:24

Python contextlib上下文管理实战:从with到ExitStack

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 14:20:31

AI辅助JS逆向:猿人学第18题动态Cookie破解

说实话&#xff0c;第一次刷到猿人学第18题的时候&#xff0c;我压根没想着能在半天内把它啃下来。题名看着就俩关键字&#xff1a;AI、逆向。但等你真正把页面打开&#xff0c;看到那串每次刷新都会变、还带着一堆混淆逻辑的__jsl_clearance_sCookie 时&#xff0c;才会意识到…

作者头像 李华
网站建设 2026/9/13 14:19:44

Android Studio新闻APP源码解析与答辩实战指南

简介&#xff1a;这是一套基于Android Studio开发的新闻APP项目源码&#xff0c;主要面向计算机专业完成本科毕业设计的学生&#xff0c;以及需要项目实战练习的初学者。项目涵盖实时新闻、娱乐、搞笑、原创、科技、体育、乐活等经典分类&#xff0c;支持每日精选编辑与优质内容…

作者头像 李华