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 循环:
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 一样,但限制在项目目录内,防止模型读到不该读的文件:
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 组件。每个工具调用渲染为一张可折叠的卡片:
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 函数现在要处理两种情况:
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 解析器现在要处理三种事件:
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_call 和 tool_result 都更新同一条消息。这样同一个 assistant turn 里的多个工具调用会聚合在一起。
试着改改
1. 默认展开工具调用
把 ToolCallCard 的初始状态改成 expanded = true,看看满屏工具调用卡片是什么感觉。用一会你就知道为什么要默认折叠了。
2. 限制结果显示长度
如果 read_file 读了一个大文件,结果会很长。给 ToolCallCard 的结果显示加一个截断:
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 就是个黑箱,你没法信任它,也没法调试它。