Skip to content

s10 - 调用可视化

前面的章节里,模型调工具的过程对你来说是个黑箱。你发一个问题,等几秒,拿到回复——中间发生了什么,不知道。 这一章要做的事情,是把工具调用的过程摊开给你看。

这一章要解决什么问题

s09 有了多会话聊天界面,状态管理也理清了。但有一个问题:当模型决定调工具的时候,界面上什么都看不到。

模型可能在背后列了目录、读了三个文件、搜了一次关键词——但用户看到的只是几秒钟的等待,然后一段文字冒出来。

这对调试是灾难性的。你不知道模型到底干了什么,也不知道它为什么给出这个回答。如果工具调用出了错,你甚至不知道错在哪一步。

这一章的目标:在界面上展示每一次工具调用——工具名、参数、结果——让用户能看到模型的完整推理过程。

架构:工具调用在哪执行

s09 的 API route 只负责转发消息给模型、流式返回文本。现在要在 API route 里加入工具调用的完整逻辑。

用户发消息

API Route(服务端)
  ├── 发给模型
  ├── 模型说要调 read_file
  ├── 服务端执行 read_file,拿到结果
  ├── 结果送回模型
  ├── 模型又说要调 list_files
  ├── 服务端执行,结果送回
  ├── 模型觉得够了,生成文字回复
  └── 通过 SSE 把整个过程流式推给前端

前端展示:文字 + 工具调用卡片

跟 Phase 1 的 s05 是同一个循环,只是跑在服务端,结果通过 SSE 推给前端。

SSE 事件格式

服务端通过三种事件把工具调用过程推给前端:

event: text_delta
data: {"chunk": "这个项目"}

event: tool_call
data: {"id": "call_1", "name": "read_file", "args": {"path": "package.json"}}

event: tool_result
data: {"id": "call_1", "result": "{\n  \"name\": \"my-project\"...}"}

event: text_delta
data: {"chunk": "使用了 React。"}

text_delta 跟 s07 一样,逐字推送文本。tool_call 在工具开始执行时发送,tool_result 在执行完成后发送。

前端拿到这些事件后,构建出消息列表:普通文本消息和工具调用卡片交错排列。

服务端:工具调用循环

API route 的核心是 runWithTools 函数——一个服务端版本的 s05 循环:

typescript
async function runWithTools(messages: Message[], stream: StreamSink) {
  const MAX_ROUNDS = 10;

  for (let round = 0; round < MAX_ROUNDS; round++) {
    const response = await client.chat.completions.create({
      model: MODEL,
      messages,
      tools,
    });

    const msg = response.choices[0].message;

    // 模型直接回复,没调工具 → 流式输出文本,结束
    if (!msg.tool_calls) {
      if (msg.content) {
        // 这里走流式输出,跟 s07 一样
        await streamTextContent(msg.content, stream);
      }
      return;
    }

    // 模型调了工具 → 逐个执行
    messages.push(msg);
    for (const tc of msg.tool_calls) {
      const name = tc.function.name;
      const args = JSON.parse(tc.function.arguments);

      // 通知前端:工具开始执行
      stream.push({ event: "tool_call", data: { id: tc.id, name, args } });

      const result = dispatchTool(name, args);

      // 通知前端:工具执行完成
      stream.push({ event: "tool_result", data: { id: tc.id, result } });

      // 结果送回模型,下一轮会看到
      messages.push({
        role: "tool",
        tool_call_id: tc.id,
        content: result,
      });
    }
  }
}

注意 messages.push(msg)——模型的回复(包含 tool_calls)也被加到消息历史里。这是 OpenAI API 的要求:tool 消息前面必须有一条带 tool_calls 的 assistant 消息。

工具实现跟 Phase 1 一样,但限制在项目目录内,防止模型读到不该读的文件:

typescript
import { readFile, readdir } from "fs/promises";
import { resolve, relative } from "path";

const PROJECT_ROOT = resolve(process.cwd(), "..");

function safePath(path: string): string {
  const abs = resolve(PROJECT_ROOT, path);
  if (!abs.startsWith(PROJECT_ROOT)) {
    throw new Error("不能读取项目目录之外的文件");
  }
  return abs;
}

前端:ToolCallCard 组件

这是这一章新增的核心 UI 组件。每个工具调用渲染为一张可折叠的卡片:

tsx
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
  const [expanded, setExpanded] = useState(false);

  return (
    <div className="border border-zinc-700 rounded-lg ...">
      {/* 头部:工具名 + 状态 */}
      <button onClick={() => setExpanded(!expanded)}>
        <span>🔧</span>
        <span>{toolCall.name}</span>
        <span>{toolCall.status === "pending" ? "⏳" : "✅"}</span>
        <span>{expanded ? "▲" : "▼"}</span>
      </button>

      {/* 展开区域:参数 + 结果 */}
      {expanded && (
        <div>
          <div>参数</div>
          <pre>{JSON.stringify(toolCall.args, null, 2)}</pre>
          {toolCall.result && (
            <>
              <div>结果</div>
              <pre>{toolCall.result}</pre>
            </>
          )}
        </div>
      )}
    </div>
  );
}

默认折叠——用户不需要每次都看工具调用的细节。但点击就能展开,看到完整的参数和结果。

状态指示器用颜色区分:执行中是黄色,完成是绿色。一目了然。

前端:消息渲染

ChatArea 的 renderMessage 函数现在要处理两种情况:

tsx
function renderMessage(msg: ChatMessage) {
  if (msg.role === "assistant" && msg.tool_calls?.length) {
    return (
      <div>
        {/* 如果有文本内容,先渲染文本 */}
        {msg.content && <MarkdownMessage content={msg.content} />}
        {/* 工具调用卡片 */}
        {msg.tool_calls.map(tc => <ToolCallCard key={tc.id} toolCall={tc} />)}
      </div>
    );
  }

  // 普通文本消息,跟 s09 一样
  return <MarkdownMessage content={msg.content} />;
}

助手消息可以同时包含文本和工具调用。先渲染文本,再渲染工具调用卡片。工具调用卡片比普通消息窄一些,背景色也不同,在视觉上形成层次。

前端:处理 SSE 事件

客户端的 SSE 解析器现在要处理三种事件:

typescript
if (eventType === "tool_call") {
  // 把工具调用加到消息里,状态是 pending
  addToolCallToMessage(currentAssistantId, {
    id: data.id,
    name: data.name,
    args: data.args,
    status: "pending",
  });
}

if (eventType === "tool_result") {
  // 更新工具调用状态,填入结果
  updateToolCallResult(currentAssistantId, data.id, data.result);
}

currentAssistantId 是当前助手消息的 ID。第一个 tool_call 事件到来时创建助手消息,后续的 tool_calltool_result 都更新同一条消息。这样同一个 assistant turn 里的多个工具调用会聚合在一起。

试着改改

1. 默认展开工具调用

把 ToolCallCard 的初始状态改成 expanded = true,看看满屏工具调用卡片是什么感觉。用一会你就知道为什么要默认折叠了。

2. 限制结果显示长度

如果 read_file 读了一个大文件,结果会很长。给 ToolCallCard 的结果显示加一个截断:

tsx
const displayResult = toolCall.result && toolCall.result.length > 500
  ? toolCall.result.slice(0, 500) + "\n...(已截断)"
  : toolCall.result;

3. 给工具调用加时间戳

tool_call 事件里加上时间戳,前端显示"执行耗时 230ms"。观察哪些工具调得快、哪些慢。

4. 加一个"展开全部"按钮

在 ChatArea 顶部加一个按钮,一键展开或折叠所有工具调用卡片。分析模型推理路径的时候很有用。

教学边界

这一章只做一件事:把工具调用的过程可视化。

不涉及:

  • 工具执行的权限控制(哪些文件能读、哪些不能)
  • 工具调用的并发执行(还是一个一个跑的)
  • 工具调用的重试或回滚
  • 用户中途取消工具执行

这些都在后面的章节里。

Phase 2 完结:你有了一个真正的聊天产品

从 s06 到 s10,你搭建了一个功能完整的 AI 聊天应用:

章节你加了什么产品有了什么能力
s06对话界面用户能打字、看回复
s07流式输出回复逐字出现,不用干等
s08内容渲染Markdown、代码高亮、一键复制
s09状态管理多会话、历史持久化
s10调用可视化工具调用过程透明可查

这不是一个 demo,是一个真正可以日常使用的产品。多会话管理、流式回复、Markdown 渲染、工具调用追踪——主流 AI 聊天产品的核心功能,你都有了。

更重要的是,从 Phase 1 到 Phase 2,你完成了一个关键的视角转换:Phase 1 你关心的是Agent 怎么工作(工具调用、结果回流),Phase 2 你关心的是用户怎么体验(界面、交互、透明度)。

Phase 3 我们要做的,是跳出"一问一答"的对话模式,进入工作流编排——用拖拽画布定义多步骤流程,让 Agent 按照你设计的路线执行任务。

一句话记住

工具调用可视化不是锦上添花——看不见推理过程的 Agent 就是个黑箱,你没法信任它,也没法调试它。