CopilotKit × LangGraph TypeScript:双向共享状态(Read + Write)的完整实现与 QA 验证指南
2026/9/13 16:13:02 网站建设 项目流程

CopilotKit × LangGraph TypeScript:双向共享状态(Read + Write)的完整实现与 QA 验证指南

【免费下载链接】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 仓库中 LangGraph (TypeScript) 集成演示的 QA 文档 shared-state-read-write.md 展开,讲解“UI 与 Agent 双向共享状态”这一核心模式:UI 通过agent.setState()写入用户偏好、Agent 每轮从状态中读取并注入 system prompt;Agent 通过set_notes工具写回状态、UI 通过useAgent()实时读取渲染。读完后你将掌握该模式的前后端完整实现链路、全部可执行测试步骤、预期结果基线,以及从源码结构确认的关键设计细节(如Command({ update })双通道写回、空偏好跳过注入的容错逻辑)。

前置条件

QA 文档明确列出了运行该演示所需的环境前提:

  • 演示已部署并可在 dashboard 主机上通过/demos/shared-state-read-write访问;
  • Agent 后端健康(/api/health可访问);
  • Railway 上已设置OPENAI_API_KEY
  • LANGGRAPH_DEPLOYMENT_URL指向一个暴露了shared_state_read_writegraph 的 LangGraph 部署。

前端入口在 page.tsx 中可见,CopilotKit组件通过runtimeUrl="/api/copilotkit"agent="shared-state-read-write"两个属性完成与运行时和后端的绑定:

export default function SharedStateReadWriteDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="shared-state-read-write"> <DemoContent /> </CopilotKit> ); }

状态模型:两个切片,四个方向的数据流

整个演示的共享状态只有一个对象,包含两个语义完全不同的切片。前端在 page.tsx 中定义了它的形状:

// Shape of the bidirectional shared state. // - `preferences` is WRITTEN by the UI via agent.setState(). // - `notes` is WRITTEN by the agent via its `set_notes` tool and READ // by the UI via useAgent(). interface RWAgentState { preferences: Preferences; notes: string[]; }

对应的Preferences结构在 preferences-card.tsx 中定义:

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

数据流可以概括为四条链路:

  1. UI → Agent(写):侧边栏表单每次编辑都经agent.setState({ preferences, notes })写入 Agent 状态;
  2. Agent ← 读:后端 chat node 每轮从state.preferences读出偏好并注入 system prompt;
  3. Agent → UI(写):Agent 调用set_notes工具更新state.notes
  4. UI ← 读 + 回写:UI 用useAgent({ updates: [OnStateChanged] })订阅状态变更实时渲染笔记卡片,Clear 按钮则以agent.setState({ notes: [] })反向写回同一个切片。

测试步骤 1:基础功能验证

QA 文档的第一组检查项聚焦页面骨架的完整性,全部可通过 DOM 断言(data-testid)完成:

  • 访问/demos/shared-state-read-write,页面应在 3 秒内渲染出左侧边栏(preferences + notes 两张卡片)与右侧CopilotChat面板;
  • 验证data-testid="preferences-card"可见,标题为 "Your preferences"——与 preferences-card.tsx 中<Card>useConfigureSuggestions({ suggestions: [ { title: "Greet me", message: "Say hi and introduce yourself." }, { title: "Remember something", message: "Remember that I prefer morning meetings and that I don't eat dairy.", }, { title: "Plan a weekend", message: "Suggest a weekend plan based on my interests.", }, ], available: "always", });
    • 发送 "Hello",10 秒内应出现助手文本回复,确认前端到 LangGraph 后端的完整链路可用。

    测试步骤 2A:UI 写入 → Agent 读取(preferences)

    这是“UI 写、Agent 读”方向的完整验证。逐步操作与预期:

    1. data-testid="pref-name"输入 "Atai",data-testid="pref-state-json"应同步更新为包含"name": "Atai"
    2. data-testid="pref-tone"改为formal,JSON 预览应反映"tone": "formal"
    3. data-testid="pref-language"改为Spanish,JSON 预览应反映"language": "Spanish"
    4. 点击CookingTravel兴趣徽章,两者应呈现选中样式(边框#BEC2FF、背景#BEC2FF1A),JSON 预览的interests数组应同时包含两项;
    5. 发送 "What do you know about me?",10 秒内助手回复应引用 "Atai"、formal 语气、Spanish 语言以及 Cooking/Travel 兴趣——因为 chat node 每轮都会把偏好注入 system prompt;
    6. 点击 "Plan a weekend" 建议,回复应贴合已选兴趣。

    源码层面,这组断言的“可解释性”来自两个组件的解耦设计。PreferencesCard是一个纯受控表单,本身完全不感知 Agent——每个字段的变更都通过set局部函数走onChange冒泡:

    export function PreferencesCard({ value, onChange }: PreferencesCardProps) { const set = <K extends keyof Preferences>(key: K, v: Preferences[K]) => onChange({ ...value, [key]: v }); // ... name / tone / language / interests 各控件 }

    真正的状态写入发生在上一层 page.tsx:

    // WRITE: every edit in the sidebar goes straight into agent state. const handlePreferencesChange = (next: Preferences) => { agent.setState({ preferences: next, notes, // preserve what the agent has written } as RWAgentState); };

    注意这里刻意携带了notes字段——UI 写偏好时必须保留Agent 已写入的笔记,否则会覆盖掉set_notes的产出。卡片底部那个pref-state-json<pre>直接JSON.stringify(value, null, 2),因此 QA 中“每次编辑后 JSON 预览同步更新”的断言是确定性的同步渲染结果。

    兴趣徽章的选中态与 QA 提到的#BEC2FF/#BEC2FF1A颜色,来自 preferences-card.tsx 中按selected ? "selected" : "outline"切换的Badge变体。

    测试步骤 2B:Agent 写入 → UI 读取(notes)

    这是反向链路的验证:

    1. 点击 "Remember something" 建议(实际发送 "Remember that I prefer morning meetings and that I don't eat dairy.");
    2. 15 秒内data-testid="notes-list"应出现在 notes 卡片中,且至少包含 2 条data-testid="note-item",分别提及 "morning meetings" 和 "dairy";
    3. data-testid="notes-empty"空态应不再渲染;
    4. 发送 "Also remember I live in Berlin.",15 秒内笔记列表应增长(旧笔记保留、新笔记追加)——这一步验证了 Agent 每次调用set_notes都传入完整更新后的列表,而非增量。

    这条“传全量、不传 diff”的契约由后端工具的描述文字直接固化。在 shared-state-read-write.ts 中:

    const setNotes = tool( async ({ notes }, config: ToolRunnableConfig) => { // ... 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. " + "Use this tool whenever the user asks you to 'remember' something, or " + "when you have an observation about the user worth surfacing in the " + "UI's notes panel. Always pass the FULL notes list (existing notes + " + "any new ones), not a diff. Keep each note short (< 120 chars).", schema: z.object({ notes: z .array(z.string()) .describe("The full updated notes list (replaces previous value)."), }), }, );

    从源码结构看,这里有两个关键设计点:

    • Command({ update })一石二鸟:同一次工具返回里既更新了notes通道(UI 侧通过共享状态立即重渲染),又追加了一条携带tool_call_idToolMessage(让 LLM 在下一轮看到格式合法的 tool result)。工具内还显式校验config.toolCall?.id非空,否则拒绝生成空tool_call_idToolMessage(OpenAI 会拒绝这类消息),说明该实现是面向真实 LLM 提供商约束做了防御性处理;
    • 渲染侧保持纯读:notes-card.tsx 只接收notesprop 并渲染编号列表,自身不触碰 Agent 状态。UI 之所以能“实时”看到 Agent 的写入,是因为父组件订阅了状态变更:
    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 ?? [];

    UseAgentUpdate.OnStateChanged使 Agent 侧的任意状态变更(包括set_notes触发的notes通道更新)都会触发组件重渲染,这正是 QA 断言“15 秒内笔记出现”的底层机制。

    测试步骤 2C:UI 回写 Agent 拥有的切片(清空笔记)

    同一个notes字段既被 Agent 写、也被 UI 写,QA 文档用三步验证这个双向回环:

    1. 有笔记存在时,data-testid="notes-clear-button"应可见(源码中该按钮仅在notes.length > 0时渲染,与断言一致);
    2. 点击 Clear,笔记列表消失,data-testid="notes-empty"重新渲染;
    3. 再问 "What do you remember about me?",Agent 不应再引用被清空的笔记——因为 UI 已通过agent.setState({ notes: [] })把状态写回。

    对应源码:

    // WRITE: let the user clear the agent-authored notes from the UI. const handleClearNotes = () => { agent.setState({ preferences, notes: [] } as RWAgentState); };

    这一步同时演示了双向共享状态的一个语义要点:UI 的清空是直接改状态,而不是发消息让 Agent 去删。下一轮对话时 Agent 从状态里读到的就是空数组,自然“忘记”了那些笔记。

    测试步骤 2D:多轮状态持久性

    • 将 tone 改为playful并添加Music兴趣,发送 "Write me a one-line haiku greeting.",回复应是俏皮风格并提及音乐;
    • 追加发送 "Do it again in French.",回复应保持 playful、切换为法语、且继续体现音乐兴趣——确认偏好在多轮对话中无需重发即持续生效(因为偏好存在 Agent 状态里,而非聊天记录里);
    • 刷新页面后,偏好应重置为默认值(tone: casuallanguage: English、空 interests、空 name),笔记也重置为空。

    刷新即重置的行为由 page.tsx 中的初始化逻辑解释:

    const INITIAL_PREFERENCES: Preferences = { name: "", tone: "casual", language: "English", interests: [], }; // Seed initial preferences + empty notes into agent state once, so the // agent has something to read on the very first turn. useEffect(() => { if (!agentState?.preferences) { agent.setState({ preferences: INITIAL_PREFERENCES, notes: [], } as RWAgentState); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []);

    即状态是按会话(per-session)由页面useEffect播种的,QA 清单中“reload 后重置”的预期结果由此得出。

    后端图结构:chat node、tool node 与偏好注入

    前端之外,Agent 侧的完整实现在 shared-state-read-write.ts。状态声明把 CopilotKit 自带的通道与业务通道合并到同一个Annotation

    export interface Preferences { name?: string; tone?: "formal" | "casual" | "playful"; language?: string; interests?: string[]; } const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec, // messages + copilotkit(actions 等) preferences: Annotation<Preferences | undefined>, notes: Annotation<string[]>, });

    “UI 写入如何影响模型”的关键在buildPreferencesMessage+chatNode:每轮调用前,从state.preferences构造一条SystemMessage拼在消息序列最前面:

    function buildPreferencesMessage(prefs: Preferences | undefined) { 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({ /* "The user has shared these preferences..." */ }); } async function chatNode(state: AgentState, config: RunnableConfig) { const model = makeChatOpenAI(config, { temperature: 0, model: "gpt-4o-mini", modelKwargs: { parallel_tool_calls: false }, }); const modelWithTools = model.bindTools!([ ...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []), ...tools, ]); const prefsMessage = buildPreferencesMessage(state.preferences); const systemMessages = prefsMessage ? [baseSystem, prefsMessage] : [baseSystem]; const response = await modelWithTools.invoke( [...systemMessages, ...state.messages], config, ); return { messages: response }; }

    这段实现直接解释了 QA 中的两条预期:QA 断言“chat node 每轮把偏好注入 system prompt”(因此 "What do you know about me?" 能答出全部偏好);同时也解释了 2E 的容错项——当preferences为空对象(所有字段 falsy)时buildPreferencesMessage返回null,chat node 跳过注入而不报错,对应 QA 中“清空偏好后问 'Who am I?',Agent 应正常作答不崩溃”。

    图编译部分则是标准的 LangGraph 结构:chat_nodeToolNode成环,MemorySaver提供 checkpoint:

    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); export const graph = workflow.compile({ checkpointer: new MemorySaver(), });

    路由函数shouldContinue有一个值得注意的 CopilotKit 专属细节:模型可能同时产生后端工具调用(如set_notes)和CopilotKit 前端 action 调用。只有当存在“不属于state.copilotkit.actions的任何工具调用”时才进入tool_node,否则直接__end__,让前端 action 走 CopilotKit 运行时通道:

    function shouldContinue({ messages, copilotkit }: AgentState) { const lastMessage = messages[messages.length - 1] as AIMessage; if (lastMessage.tool_calls?.length) { const actions = copilotkit?.actions; const hasBackendToolCall = lastMessage.tool_calls.some((toolCall) => !actions || actions.every((action) => action.name !== toolCall.name) ); if (hasBackendToolCall) return "tool_node"; } return "__end__"; }

    测试步骤 3:错误处理与边界情况

    QA 文档列出的三条边界检查及源码依据:

    • 空消息发送应为 no-op:不产生用户气泡、不产生助手回复;
    • 清空全部偏好与姓名后问 "Who am I?":Agent 正常作答不崩溃——源码依据即上文buildPreferencesMessagelines.length === 0 → return null分支;
    • 全程 DevTools Console 无未捕获错误:这是跨所有流程的全局不变量。

    预期结果基线

    汇总 QA 文档给出的验收基线,可用于回归时快速对照:

    • 页面 3 秒内完成加载;助手文本回复在 10 秒内出现;
    • 偏好写入在每次变更时同步反映到pref-state-json预览;
    • Agent 写入的笔记在 "remember" 类提示后 15 秒内出现在notes-card,且后续每次set_notes调用都保留此前的完整列表;
    • Clear 按钮完成 UI → Agent 状态的回环,下一轮对话中 Agent 不再拥有被清空的笔记;
    • 全程无 UI 布局破坏、无未捕获的控制台错误。

    小结与延伸阅读

    这个演示是 LangGraph TypeScript + CopilotKit 双向共享状态的最小完整范式:一个共享状态对象、两个方向相反的读写切片、四个数据流,且每一侧的代码都保持单一职责(卡片组件不碰 Agent,状态接线集中在 page 层)。相关可追溯的仓库文件:

    • QA 清单(本文主体):shared-state-read-write.md
    • 演示说明:README.md
    • 前端页面与状态接线:page.tsx、preferences-card.tsx、notes-card.tsx、suggestions.ts
    • 后端 Agent 图:shared-state-read-write.ts

    需要说明的适用前提:以上路径均位于showcase/integrations/langgraph-typescript集成演示包内,运行依赖已部署的 CopilotKit runtime(/api/copilotkit)与暴露shared_state_read_write图的 LangGraph 部署;QA 清单中个别文案(notes 空态占位文本)与当前仓库代码存在版本差异,执行自动化断言前建议以检出时的源码为准校准。

    【免费下载链接】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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询