React 驱动的 AI Agent 交互协议:实现声明式工具调用反馈

嘿,各位未来的架构师们,还有那些正在为“让 AI 乖乖听话”而掉头发的开发者们,大家好!

欢迎来到今天的讲座,主题是——《React 驱动的 AI Agent 交互协议:实现声明式工具调用反馈》

坐下来,喝口水。别紧张,虽然我们谈论的是 AI,但今天咱们不聊那些玄之又玄的神经网络权重,也不聊那些让你血压升高的 Prompt Engineering。我们要聊的是架构,是模式,是如何用 React 那我们最熟悉的 useStateuseEffect,去驯服那只名叫 Agent 的野兽。

想象一下,AI Agent 不是一个只会聊天、稍微有点像 Siri 的机器人。它是一个真正的多面手。它需要查天气、算汇率、订机票,甚至帮你把隔壁老王家的狗叫回来。如果还是那种“用户问一句,AI 回一句”的聊天模式,那就太掉价了。那不是 Agent,那是个复读机。

我们要构建的是一个状态机驱动的交互系统。而 React,正是实现这个状态机的绝佳工具。

准备好了吗?让我们把那堆乱七八糟的回调函数扔进垃圾桶,开始正题。


第一部分:从“命令式”到“声明式”——告别手动挡

在深入协议之前,我们要先吐槽一下传统的 Agent 调用方式。如果你以前写过这种代码,你懂的。

传统的“手动挡”调用方式(也就是命令式编程的噩梦):

// 糟糕的代码示例,仅供怀念
const handleUserMessage = async (text: string) => {
  setLoading(true);

  try {
    // 1. 把用户的话扔给 LLM
    const response = await api.chat(text);

    // 2. 解析 JSON(如果 LLM 没写错引号,或者多打了个 Tab,恭喜你,报错)
    const toolCall = JSON.parse(response.toolCall);

    // 3. 判断它要干嘛
    if (toolCall.name === 'get_weather') {
      // 4. 手动去调用函数
      const weatherData = await getWeather(toolCall.args.city);

      // 5. 把结果塞回去
      await api.chatWithToolResult(response.id, weatherData);

      // 6. 再次解析结果,显示给用户
      const finalResponse = await api.chat(response.id);
      addMessage(finalResponse.text);
    }
  } catch (error) {
    console.error("我的天,它又疯了", error);
  } finally {
    setLoading(false);
  }
};

看到没?这就是所谓的“面条代码”。你的 React 组件不得不去关心 LLM 的输出格式、去关心工具的执行顺序、去关心错误重试。这就像是你开了辆车,但你还得自己换轮胎、自己加油、自己修发动机。累不累?累死了!

我们想要的“自动挡”模式:

React 的核心理念是声明式。你只描述“UI 应该是什么样子的”,React 负责帮你把那该死的 DOM 更新了。

同样,对于 Agent,我们也需要声明式协议。我们要告诉 React:“嘿,Agent 现在处于 THINKING 状态,UI 就显示一个加载圈。当它调用 get_weather 工具时,UI 就把 TOOL 类型的消息渲染上去。当它完成任务时,渲染最终的回复。”

这就是我们要讲的协议的核心:将 Agent 的内部状态(Thinking -> Calling -> Result -> Done)映射到 React 的组件 Props 或 State 上。


第二部分:协议层设计——定义“语言”

在写代码之前,我们需要定义 Agent 说话的“语法”。这个语法就是我们的协议层。它不应该依赖任何特定的 LLM 后端,它应该是纯前端逻辑。

我们定义一个核心概念:Tool(工具)。在 React 的世界里,工具就是一个对象,它有名字、描述和参数 schema。

// 定义工具的契约
interface Tool<TInput = any, TOutput = any> {
  name: string;
  description: string;
  // 执行逻辑,纯函数风格最好
  execute: (input: TInput) => Promise<TOutput> | TOutput;
}

// 这是一个具体的工具:查天气
const getWeatherTool: Tool<{ city: string }> = {
  name: 'get_weather',
  description: '获取指定城市的实时天气信息',
  execute: async ({ city }) => {
    // 模拟网络请求
    return new Promise(resolve => {
      setTimeout(() => resolve({ city, temp: 26, condition: '晴朗' }), 1000);
    });
  }
};

现在,我们需要一个协议来描述 Agent 的“意图”。在 React 中,这个意图通常表现为ActionEvent

// Agent 的动作流
type AgentAction = 
  | { type: 'USER_INPUT'; payload: string }
  | { type: 'AGENT_THINKING'; payload: string }
  | { type: 'TOOL_CALL'; payload: { toolName: string; args: any } }
  | { type: 'TOOL_RESULT'; payload: { toolName: string; result: any } }
  | { type: 'AGENT_RESPONSE'; payload: string };

// Agent 的状态机
type AgentStatus = 'IDLE' | 'THINKING' | 'EXECUTING' | 'WAITING_FOR_INPUT';

第三部分:核心实现——自定义 Hook useAgent

这是今天的重头戏。我们将创建一个 React Hook,它像一个智能管家一样,管理着整个对话流程。这个 Hook 接收用户输入,处理状态流转,并负责将 Agent 的行为反馈给 UI。

import { useState, useCallback } from 'react';

// 假设我们有一个 LLM 服务
const mockLLM = async (input: string) => {
  return {
    text: "好的,我来帮你查一下北京的天气。",
    tool_calls: [
      { name: 'get_weather', args: { city: '北京' } }
    ]
  };
};

export const useAgent = (tools: Map<string, Tool>) => {
  // UI 需要展示的消息列表
  const [messages, setMessages] = useState<any[]>([]);
  // Agent 当前所处的“精神状态”
  const [status, setStatus] = useState<AgentStatus>('IDLE');
  // 这是一个用来跟踪当前正在进行的工具调用的 ID(防止并发问题)
  const [activeToolId, setActiveToolId] = useState<string | null>(null);

  // 添加消息到 UI
  const addMessage = useCallback((role: 'user' | 'agent' | 'tool', content: string) => {
    setMessages(prev => [...prev, { role, content, timestamp: Date.now() }]);
  }, []);

  // 处理用户输入
  const handleUserInput = useCallback(async (text: string) => {
    // 1. 状态重置
    setStatus('THINKING');
    setMessages(prev => [...prev, { role: 'user', content: text }]);

    try {
      // 2. 请求 LLM
      const response = await mockLLM(text);

      // 3. 将 Agent 的思考过程渲染出来
      addMessage('agent', response.text);

      // 4. 如果 LLM 决定调用工具
      if (response.tool_calls && response.tool_calls.length > 0) {
        for (const toolCall of response.tool_calls) {
          // 声明式反馈:告诉 UI "我要去执行工具了"
          setStatus('EXECUTING');
          addMessage('tool', `[正在调用: ${toolCall.name}...]`);

          try {
            const tool = tools.get(toolCall.name);
            if (!tool) {
              console.error(`工具不存在: ${toolCall.name}`);
              addMessage('tool', `[错误: ${toolCall.name} 工具不可用]`);
              continue;
            }

            // 执行工具
            const result = await tool.execute(toolCall.args);

            // 声明式反馈:告诉 UI "工具执行完毕,结果是..."
            addMessage('tool', `[${toolCall.name} 结果: ${JSON.stringify(result)}]`);

            // 这里通常需要把结果喂回 LLM,但在演示中我们简单处理
            addMessage('agent', `查到了,${toolCall.name} 的结果是 ${JSON.stringify(result)}`);

          } catch (err) {
            addMessage('tool', `[工具 ${toolCall.name} 执行失败]`);
          }
        }
        // 5. 最后给个总结
        addMessage('agent', "以上就是我能提供的所有帮助。");
        setStatus('IDLE');
      } else {
        setStatus('IDLE');
      }
    } catch (error) {
      addMessage('agent', "哎呀,服务器抽风了,请稍后再试。");
      setStatus('IDLE');
    }
  }, [tools, addMessage]);

  return { messages, status, handleUserInput };
};

看懂了吗?这就是声明式的精髓。
在 Hook 内部,我们负责处理复杂的异步逻辑(LLM 交互、工具调度、错误处理)。但在外部组件中,我们只需要关心 messages 数组和 status 状态。

外部组件怎么写?简直简单得令人发指。

// 组件调用示例
const WeatherAgent = () => {
  const { messages, status, handleUserInput } = useAgent(new Map([['get_weather', getWeatherTool]]));

  return (
    <div className="agent-container">
      <div className="chat-window">
        {messages.map(msg => (
          <div key={msg.timestamp} className={`msg ${msg.role}`}>
            {msg.content}
          </div>
        ))}
        {status === 'THINKING' && <div className="typing-indicator">Agent 正在思考...</div>}
        {status === 'EXECUTING' && <div className="tool-indicator">Agent 正在拧螺丝...</div>}
      </div>
      <input 
        type="text" 
        onKeyPress={(e) => e.key === 'Enter' && handleUserInput(e.currentTarget.value)}
      />
    </div>
  );
};

就是这个! UI 代码里没有 JSON.parse,没有 await tool.execute,没有 try/catch 嵌套地狱。这就是我们要的“反馈协议”。


第四部分:优化交互——让反馈更“懂人心”

光有状态切换还不够。作为资深开发者,我们要追求极致的用户体验。当一个 Agent 调用工具时,用户可能会觉得卡住了,或者不知道它到底在干嘛。我们需要更细腻的反馈。

我们可以引入一个“中间状态”。比如,在 EXECUTING 之前,加一个 RECEIVING_INTENT 状态。

type AgentStatus = 
  | 'IDLE' 
  | 'RECEIVING_INTENT' // 正在接收指令
  | 'EXECUTING'        // 正在干活
  | 'IDLE'; 

// 在 Hook 中扩展逻辑
const handleUserInput = useCallback(async (text: string) => {
  // ...省略用户消息添加...

  setStatus('RECEIVING_INTENT');
  const response = await mockLLM(text); // 这里的 mockLLM 模拟网络延迟

  // 如果模型返回了工具调用
  if (response.tool_calls) {
    setStatus('EXECUTING');
    addMessage('tool', `[正在连接服务器: ${response.tool_calls[0].name}]`);

    // 执行工具
    const result = await tool.execute(...);

    // 完成反馈
    addMessage('tool', `[数据已返回: ${result.temp}度]`);
  }
}, []);

这不仅仅是一个状态机,这是一个叙事流。我们在 UI 上构建了一个故事:用户说话 -> Agent 倾听 -> Agent 决定行动 -> Agent 行动 -> 结果展示。


第五部分:并发与流式反馈——让 Agent 看起来更聪明

很多 Agent 的工具调用(比如搜索、数据库查询)是很慢的。如果我们还是按顺序执行,用户就得盯着屏幕傻等。

React 支持并发模式,我们的协议也应该支持。我们可以把“流式反馈”加进去。

想象一下,我们定义一个 StreamEvent 协议:

type StreamEvent = 
  | { type: 'content_chunk', text: string }
  | { type: 'tool_started', toolName: string }
  | { type: 'tool_progress', percent: number, log: string }
  | { type: 'tool_finished', result: any };

// 在 Hook 中处理流
const handleToolExecution = async (toolName: string) => {
  const eventEmitter = new EventTarget(); // 或者用 RxJS

  // 1. 立即反馈:开始
  eventEmitter.dispatchEvent(new CustomEvent('tool_started', { detail: { toolName } }));

  // 2. 模拟流式输出进度
  for (let i = 0; i <= 100; i+=10) {
    await new Promise(r => setTimeout(r, 200));
    eventEmitter.dispatchEvent(new CustomEvent('tool_progress', { detail: { percent: i, log: `正在查询数据库...` } }));
  }

  // 3. 完成
  eventEmitter.dispatchEvent(new CustomEvent('tool_finished', { detail: { result: 'OK' } }));
};

// 组件监听这些事件
const useAgentWithStreams = () => {
  const [events, setEvents] = useState<StreamEvent[]>([]);

  useEffect(() => {
    // ...监听逻辑...
  }, []);

  return <AgentUI events={events} />;
};

当 Agent 调用 useAgentWithStreams 时,它会吐出一连串的 UI 事件。这就像看火车进站,一节一节地进,而不是等整列火车都进站才让你看。


第六部分:异常处理与重试机制——容错的艺术

Agent 不是神仙,它会犯错,LLM 会幻觉(一本正经地胡说八道)。

我们的协议必须包含错误边界。当工具调用失败时,Agent 需要有一个“自我纠错”的能力。

协议设计:

type AgentAction = 
  | { type: 'USER_INPUT'; payload: string }
  | { type: 'TOOL_CALL'; payload: { toolName: string; args: any; retryCount: number } }
  | { type: 'ERROR'; payload: { message: string; context: any } };
// 增强版的 Hook 逻辑
const handleToolCall = async (toolCall) => {
  try {
    const result = await tool.execute(toolCall.args);
    return { type: 'TOOL_RESULT', payload: result };
  } catch (error) {
    // 如果是第一次调用失败,让 Agent 重试
    if (toolCall.retryCount < 2) {
      return { type: 'TOOL_CALL', payload: { ...toolCall, retryCount: toolCall.retryCount + 1 } };
    }
    // 重试两次还不行,那就报错吧
    return { 
      type: 'ERROR', 
      payload: { message: `工具 ${toolCall.toolName} 执行失败: ${error.message}` } 
    };
  }
};

在 UI 层,我们只需要处理 ERROR 这个 Action,然后弹出一个漂亮的 Toast,而不是控制台的一堆红字。这种错误信息的抽象,正是 React 驱动的协议带来的红利。


第七部分:实战演示——一个“智能旅行规划师”

让我们把所有东西串起来。创建一个能真正规划旅行的 Agent。

1. 定义工具集:

const travelTools = new Map([
  [
    'search_flights',
    {
      name: 'search_flights',
      description: '搜索从 A 到 B 的航班',
      execute: async (args) => {
        // 真实的 API 调用逻辑
        return {
          flights: [
            { id: 'CA1234', price: 500, time: '10:00' },
            { id: 'MU5678', price: 480, time: '14:30' }
          ]
        };
      }
    }
  ],
  [
    'book_hotel',
    {
      name: 'book_hotel',
      description: '预订酒店',
      execute: async (args) => {
        return { success: true, bookingId: 'HOTEL_998877' };
      }
    }
  ]
]);

2. 定义协议处理器:

const agentProtocol = {
  user: async (text) => {
    // LLM 返回 JSON
    return JSON.stringify({
      thought: "用户想去巴黎,我需要查机票和订酒店",
      tools: [
        { name: 'search_flights', args: { destination: 'Paris' } },
        { name: 'book_hotel', args: { city: 'Paris' } }
      ]
    });
  },
  tool: async (toolName, args) => {
    const tool = travelTools.get(toolName);
    return tool ? await tool.execute(args) : "工具未定义";
  }
};

3. UI 渲染(声明式!):

const TravelAgent = () => {
  const { messages, status, handleUserInput } = useAgent(travelTools);

  return (
    <div className="travel-agent">
      <header>🤖 智能旅行规划师</header>
      <div className="logs">
        {messages.map((msg, i) => {
          if (msg.role === 'tool') {
            // 简单的工具调用 UI
            return (
              <div key={i} className="log-entry tool-call">
                <span className="icon">🔧</span>
                <span>{msg.content}</span>
              </div>
            );
          }
          return <div key={i} className={`log-entry ${msg.role}`}>{msg.content}</div>;
        })}

        {/* 动态状态展示 */}
        {status === 'THINKING' && <div className="status-pill">🧠 计划中...</div>}
        {status === 'EXECUTING' && <div className="status-pill">⚙️ 执行中...</div>}
      </div>

      <input 
        placeholder="比如:我想去巴黎玩三天" 
        onKeyDown={e => e.key === 'Enter' && handleUserInput(e.target.value)}
      />
    </div>
  );
};

发生了什么?
用户输入 -> Hook 状态变 THINKING -> UI 显示 “🧠 计划中…” -> LLM 返回工具调用 -> Hook 状态变 EXECUTING -> UI 显示 “⚙️ 执行中…” -> UI 渲染工具调用的结果。

整个过程,React 组件完全不知道 search_flights 需要联网,也不知道 book_hotel 需要密码。它只知道:“哦,有一个 tool 类型的消息来了,把它渲染出来就行。”

这就是关注点分离的最高境界。


第八部分:进阶话题——流式解析与协议的演进

如果你觉得上面的代码太“同步”了,那是因为为了讲座的易懂性。在实际生产中,LLM 的输出是流式的。我们需要一个协议来处理这种“边吐边吃”的情况。

我们可以将协议升级为事件流

// 协议:事件流
type AgentStreamEvent = 
  | { type: 'TEXT', chunk: string }
  | { type: 'TOOL_START', name: string }
  | { type: 'TOOL_PROGRESS', data: any };

// 组件监听流
const useAgentStream = () => {
  const [events, setEvents] = useState<AgentStreamEvent[]>([]);

  const streamAgent = async (prompt: string) => {
    // 建立流连接
    const response = await fetchLLMStream(prompt);

    response.on('data', (chunk) => {
      // 协议解析器
      if (chunk.type === 'text') {
        setEvents(prev => [...prev, { type: 'TEXT', chunk }]);
      } else if (chunk.type === 'tool') {
        setEvents(prev => [...prev, { type: 'TOOL_START', name: chunk.name }]);
        executeTool(chunk.name, chunk.args);
      }
    });
  };
};

这种方式让 Agent 的表现非常流畅。你可以一边看着它打字,一边看着它调用工具,就像看一部科技电影一样。


第九部分:调试——如何在 Agent 的世界里导航

写 React 你可以 console.log,写 Agent 协议你甚至可以 console.log,但你需要更好的方式。因为 Agent 的状态是复杂度爆炸的。

我推荐实现一个“上帝之眼”面板

// 调试面板组件
const AgentDebugger = ({ agentState }) => {
  return (
    <div className="debug-panel">
      <h3>Agent 内部状态</h3>
      <div className="state-grid">
        <div className="state-item">
          <span>Status:</span>
          <span className={agentState.status}>{agentState.status}</span>
        </div>
        <div className="state-item">
          <span>Tools:</span>
          <ul>
            {agentState.currentTools.map(t => <li key={t.name}>{t.name}</li>)}
          </ul>
        </div>
        <div className="state-item">
          <span>Memory:</span>
          <pre>{JSON.stringify(agentState.memory, null, 2)}</pre>
        </div>
      </div>
    </div>
  );
};

在开发阶段,把这个组件挂载在 App 根节点。当你点击“查天气”时,你能清晰地看到:Agent 先是在思考,然后决定调用 get_weather,它把 city: 'Beijing' 存进了内存,状态变成了 EXECUTING

这种可视化的反馈机制,是调试复杂协议的神器。


第十部分:总结——拥抱未来

好了,朋友们,咱们讲得差不多了。

我们今天没有教你怎么训练一个 Transformer 模型,也没有教你如何优化 GPU 算力。我们教你的是一种思维方式

传统的 Agent 开发,就像是在黑盒子里跳舞,你不知道机器什么时候会绊倒。而基于 React 的声明式协议,就像是给机器装上了透明玻璃

  • 工具调用 不再是一串神秘的 JSON 字符串,而是一个清晰的 ToolCall Action。
  • 执行过程 不再是后台默默的进程,而是一个可观测的 Status 状态。
  • 反馈 不再是突兀的文字跳变,而是一个顺滑的、符合人类直觉的 UI 流程。

这种架构的优势是显而易见的:

  1. 可维护性:逻辑在 Hook 里,UI 在组件里,互不干扰。
  2. 可扩展性:加一个新的工具?只需要在 Map 里加一行配置,UI 无需改动。
  3. 用户体验:状态清晰,反馈及时,拒绝“转圈圈”式的等待。

所以,当你下次设计 Agent 系统时,问自己一个问题:“这看起来像是一个复杂的后端进程,还是一个响应式的前端状态机?”

如果是前者,请回头重学 React;如果是后者,恭喜你,你掌握了一种构建未来应用的利器。

祝你们的 Agent 们永远听话,你们的 UI 永远丝滑!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注