在聊天流内实现人工审批:CopilotKit 的 In-Chat HITL(Booking)前端定义工具实战
2026/9/12 17:49:10 网站建设 项目流程

在聊天流内实现人工审批:CopilotKit 的 In-Chat HITL(Booking)前端定义工具实战

【免费下载链接】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 应用常常需要在对话中途插入"人"的决策点:确认预约时间、审批操作、选择分支等。CopilotKit 的 In-Chat HITL(Human-in-the-Loop)提供了一种极简做法——把决策 UI 直接渲染进聊天流,由前端用useHumanInTheLoop定义工具,后端 agent 完全不需要实现中断逻辑。本文以showcase/integrations/claude-sdk-python集成中的 Booking 示例(book_call预约工具)为主线,逐步拆解前端工具定义、交互卡片渲染、Claude Agent SDK 后端转发,以及底层useHumanInTheLoop的原理实现,帮助你在自己的 Agent 聊天应用中快速落地同类交互。

一、In-Chat HITL 的核心思路:前端定义工具,后端零中断

传统 HITL(如 LangGraph 的interrupt())依赖后端把 agent 执行挂起、由外部恢复。而 In-Chat HITL 把"等待人工决策"这件事完全放在前端:

  • book_call工具由前端通过useHumanInTheLoop定义,后端没有对应的工具实现;
  • 后端只是一个"基本 Claude Agent SDK 聊天循环",负责把 AG-UI(Agent UI 协议)携带的前端工具定义原样转发给 Claude,并让标准 tool-call 生命周期解析用户的最终选择;
  • 没有任何后端中断:工具"执行中"时前端渲染TimePickerCard(时间段选择卡片),用户点选后respond(...)把结果像普通工具返回值一样送回 agent。

这段描述直接来自示例文档(README.md),而示例的后端代码注释也印证了这一点:

"""The `book_call` tool is defined on the FRONTEND via `useHumanInTheLoop`, so there is no backend tool here. The agent simply responds in chat and relies on the standard frontend-tool / tool-call lifecycle to invoke `book_call` when the user asks to book."""

引用位置:hitl_in_chat_agent.py

该 demo 同时提供了 LangGraph 参考实现(hitl_in_chat_agent.py),后端同样使用tools=[],仅挂载CopilotKitMiddleware即可接收前端建议与渲染 hook——两个后端框架殊途同归,都证明"前端工具"是跨框架的统一机制。

二、前端第一步:用useHumanInTheLoop声明一个聊天内工具

示例前端代码位于 page.tsx,核心注册代码如下:

useHumanInTheLoop({ agentId: "hitl-in-chat", name: "book_call", description: "Ask the user to pick a time slot for a call. The picker UI presents fixed candidate slots; the user's choice is returned to the agent.", parameters: z.object({ topic: z .string() .describe("What the call is about (e.g. 'Intro with sales')"), attendee: z .string() .describe("Who the call is with (e.g. 'Alice from Sales')"), }), render: ({ args, status, respond }: any) => ( <TimePickerCard topic={args?.topic ?? "a call"} attendee={args?.attendee} slots={slots} status={status} onSubmit={(result) => respond?.(result)} /> ), });

各字段的作用与约束如下:

字段含义示例值 / 说明
agentId注册到哪个 agent 名下必须与<CopilotKit agent="hitl-in-chat">、后端路由 agent 名一致
name工具名,是 LLM 调用的标识book_call,前后端不实现同名工具
description发给 LLM 的工具说明,决定模型何时调用要写明"UI 会展示候选时间段,用户选择会返回给 agent"
parameterszod schema,定义工具入参topic(通话主题)、attendee(通话对象)
render渲染函数,接收args / status / respond渲染TimePickerCard并回传结果

2.1 组件挂载与 agent 绑定

整个 demo 由CopilotKitProvider 包住,runtimeUrl指向/api/copilotkitagent指定后端路由名,聊天组件用CopilotChat

<CopilotKit runtimeUrl="/api/copilotkit" agent="hitl-in-chat"> ... <CopilotChat agentId="hitl-in-chat" className="h-full rounded-2xl" /> </CopilotKit>

从源码结构看,useHumanInTheLoop依赖useCopilotKit()拿到全局copilotkit实例,并通过useFrontendTool在布局阶段完成注册,因此它必须在 Provider 内部使用。

2.2 预置建议(suggestions),降低用户输入门槛

Chat组件内还调用useConfigureSuggestions提供两条可点击的示例提示,方便测试与演示:

useConfigureSuggestions({ suggestions: [ { title: "Book a call with sales", message: "Please book an intro call with the sales team to discuss pricing.", }, { title: "Schedule a 1:1 with Alice", message: "Schedule a 1:1 with Alice next week to review Q2 goals.", }, ], available: "always", });

候选时间段由buildDefaultSlots()动态生成:默认给出"明天 10:00、明天 14:00、下周一 9:00、下周一 15:30"四个槽位,每个槽位包含label(人类可读文案)与iso(ISO 时间戳),后续作为工具返回值回传 agent。

三、交互卡片:TimePickerCard 的状态机

决策 UI 是 HITL 的"人机接口",实现在 time-picker-card.tsx。

3.1 状态定义

export type TimePickerStatus = "inProgress" | "executing" | "complete";
  • inProgress:模型正在生成 tool call(卡片可先展示占位信息);
  • executing:工具正在"执行",此时respond可用,用户可点选;
  • complete:已提交结果,卡片只读。

这与 react-core 中ToolCallStatus.InProgress / Executing / Complete三种状态一一对应(见 use-human-in-the-loop.tsx),组件内部用disabled判断做防重复提交:

const disabled = status !== "executing" || picked !== null || cancelled;

3.2 三种渲染分支

组件根据内部状态渲染三种形态,并带有data-testid供端到端测试断言:

  • 已取消:显示 "Cancelled — no time picked."(data-testid="time-picker-cancelled");
  • 已选择:显示 "Booked for {label}" 绿色确认条(data-testid="time-picker-picked");
  • 待选择:展示主题、对象与 2×2 时间段按钮网格,以及"None of these work"取消按钮(data-testid="time-picker-card")。
onClick={() => { setPicked(s); onSubmit({ chosen_time: s.iso, chosen_label: s.label }); }}
onClick={() => { setCancelled(true); onSubmit({ cancelled: true }); }}

可以看到:用户无论"选择"还是"取消",都通过同一个onSubmit回调把结果返回给 agent。返回值有两种合法形状——{ chosen_time, chosen_label }{ cancelled: true }(由TimePickerCardProps.onSubmit类型定义约束),这为后端/LLM 判断后续流程提供了依据。

四、后端零中断:Claude Agent SDK 如何转发前端工具

后端实现位于 hitl_in_chat_agent.py,是典型的 AG-UI 服务端流式接口:接收RunAgentInput,产出编码后的 AG-UI 事件流。

4.1 系统提示与模型

SYSTEM_PROMPT = dedent(""" You help users book an onboarding call with the sales team. When they ask to book a call, call the frontend-provided `book_call` tool with a short topic and the user's name (use a sensible placeholder like 'Alice from Sales' if no attendee was specified). Keep any chat reply to one short sentence. """).strip()

调用时模型从环境变量读取:normalize_claude_model(os.getenv("ANTHROPIC_MODEL", "claude-opus-4-8"))max_tokens=1024,且仅在tools非空时才把工具定义传给 Claude——正常运行时 AG-UI 会带来前端定义的book_call,因此实际都会带上。

4.2 关键点一:把 AG-UI 消息转换为 Anthropic 格式

AG-UI 提供三类消息角色,后端必须做映射:

  • user→ Anthropic 用户消息;
  • assistant→ Anthropic assistant 消息,若带tool_calls(AG-UI 的AssistantMessage把工具调用存在tool_calls字段而非content),需转成content里的tool_use块;
  • tool→ 已解析的前端工具结果,必须转成 Anthropic 的"role": "user"+content[].type == "tool_result"结构,并用tool_use_id与之前的tool_use配对。

这正是"用户点选时间段后,runtime 重新调用 agent,agent 必须看到完整 tool 调用历史"的底层机制。源码注释明确说明:CopilotKit runtime 在用户解析前端工具后(例如在book_callHITL UI 里选了一个时间段)会重新调用本 agent,此时消息数组包含 ①带tool_use的 assistant 消息 ②携带解析结果的 tool 消息。

4.3 关键点二:把 AG-UI 的前端工具定义转发给 Claude

tools: list[dict[str, Any]] = [] for t in input_data.tools or []: name = getattr(t, "name", None) or (t.get("name") if isinstance(t, dict) else None) description = getattr(t, "description", None) or ( t.get("description", "") if isinstance(t, dict) else "" ) parameters = getattr(t, "parameters", None) or ( t.get("parameters", {}) if isinstance(t, dict) else {} ) if not name: continue tools.append({ "name": name, "description": description or "", "input_schema": parameters or {"type": "object", "properties": {}}, })

AG-UI 的 Tool schema 是{ name, description, parameters }(JSON-Schema),Claude API 期望input_schema,两者形状一致,直接映射即可。这个循环是"前端定义工具、后端自动感知"的关键——agent 代码里没有任何book_call的硬编码,全靠input_data.tools透传。

4.4 关键点三:流式事件转发

run_hitl_in_chat_agent是异步生成器,用EventEncoder编码 AG-UI 事件:

  • 先发RunStartedEvent,再发TextMessageStartEvent
  • client.messages.stream(...)消费 Anthropic 流式响应:
    • 文本增量 →TextMessageContentEvent
    • 工具块开始 →ToolCallStartEvent
    • 参数 JSON 增量 →ToolCallArgsEvent
    • 工具块结束 →ToolCallEndEvent
  • 收尾发TextMessageEndEventRunFinishedEvent

工具调用事件的父消息 ID 固定为msg-{run_id}-0,从而把工具调用挂到同一条 assistant 消息上,前端据此把卡片渲染在正确的气泡上下文里。

4.5 为什么不会死循环

文档注释说明了这里的"回合"语义:前端useHumanInTheLoop解析book_call后,runtime 会把解析结果注入下一轮对话,而每一轮对话都是独立的 POST 请求,因此本 agent 发完RunFinishedEvent即交还控制权,不会在单个请求内自循环。

五、底层原理:useHumanInTheLoop 在 react-core 中如何工作

要理解 In-Chat HITL,值得看一眼它的实现(use-human-in-the-loop.tsx)。

5.1 respond 本质是一个可被 resolve 的 Promise

useHumanInTheLoop内部把respond与一个 promise 的 resolve 函数绑定:

const respond = useCallback(async (result: unknown) => { if (resolvePromiseRef.current) { cleanupAbortRef.current?.(); cleanupAbortRef.current = null; resolvePromiseRef.current(result); resolvePromiseRef.current = null; } }, []);

同时它构造了一个handler,返回一个在人工响应前一直挂起的 Promise。这个handler就是前端工具的"执行体"——工具被调用时挂起等待,respond(result)被调用时 Promise 被 resolve,结果继续走标准 tool-call 生命周期回到 agent。这就是"前端在工具执行时渲染卡片、点选后返回结果"的底层等价物。

5.2 abort 支持:取消/中断时不静默丢结果

handler接收context.signal(AbortSignal):若信号已中止则立即 reject;否则注册一次性abort监听,中止时清理引用并 reject("Human-in-the-loop interaction aborted")。cleanupAbortRef确保 promise 一旦 settle(respond 或 abort),监听器立刻移除、不会二次触发或泄漏。useLayoutEffect清理函数还会在卸载时调用copilotkit.removeHookRenderToolCall(tool.name, tool.agentId),避免组件卸载后仍残留渲染器。

5.3 渲染器按状态注入 respond

RenderComponent根据props.status分支:

  • InProgress/Complete:注入respond: undefined(此时不可交互);
  • Executing:注入respond(此时可交互)。

也就是说respond只在工具"执行中"这一生命周期窗口内是活函数,与前端组件用status !== "executing"禁用按钮的逻辑完全一致。

5.4 注册机制:useFrontendTool

useHumanInTheLoop最终把{ ...tool, handler, render }交给useFrontendTool(use-frontend-tool.tsx),后者在useLayoutEffect中:

  • 同名同 agent 已注册时先removeTooladdTool(覆盖式注册,并给出 warning);
  • 通过addHookRenderToolCall注册渲染器(注意注释:即使parameters未定义也要注册 render,HITL 确认对话框正是这种"无参数但有 UI"的工具);
  • 卸载时removeTool故意不移除 render,让历史消息中的卡片仍可渲染。

这解释了为什么聊天历史里已经完成的 HITL 卡片依然可见——它们是渲染 hook 而非活动工具。

六、运行方式与扩展建议

6.1 运行该 demo

示例属于 Claude SDK Python 集成示例应用(showcase/integrations/claude-sdk-python):

  1. 配置环境变量:ANTHROPIC_API_KEY(必填)、可选ANTHROPIC_MODEL(默认claude-opus-4-8);
  2. 启动集成示例的前端(src/app,Next.js)与后端 agent 服务,将前端runtimeUrl指向/api/copilotkit路由;
  3. 在聊天中输入"Book a call with sales"(或直接点击预置 suggestion),模型将调用book_call,聊天流内出现时间选择卡片;
  4. 点选一个时间段或取消,结果回传 agent,agent 输出简短确认语。

注意:agent 的系统提示要求"任何聊天回复保持一句话",目的是让演示聚焦工具交互本身。

6.2 扩展思路

  • 更多决策形态:替换render里的卡片组件即可实现确认弹窗、下拉选择、多选审批等;返回值形状由onSubmit类型决定,保持与 LLM 可读性即可。
  • 多工具 HITL:每个决策点声明一个useHumanInTheLoop工具即可;若希望一个渲染器覆盖多个工具,react-core 支持通配工具名"*"WILDCARD_TOOL_NAME,见 use-human-in-the-loop.tsx),此时渲染器收到的props.name是真正被调用的工具名。
  • 对比后端中断:本方案适合"决策点简单、UI 内嵌聊天"的场景;若需要服务端状态持久化、审批流恢复等能力,则考虑基于中断的 HITL 方案。

七、小结

In-Chat HITL 的精髓在于把"人工决策"抽象成前端定义的工具:前端负责注册工具、渲染交互卡片、回传结果;后端只需遵循 AG-UI 协议,把input_data.tools里的前端工具定义透传给 LLM,再按标准 tool-call 生命周期处理。CopilotKit 的useHumanInTheLoop用 Promise 挂起 +respondresolve + 渲染器状态注入这套机制,在 react-core 中完整实现了这一模式,且对 Claude Agent SDK 与 LangGraph 两类后端同样适用。掌握它,你就能在任何 CopilotKit 聊天应用中低成本加入"人机协作"环节。

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

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

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

立即咨询