在 AI 代码生成项目里接入 Thought:别展示"玄学思维链",只展示用户真正关心的工具调用
本文记录在 AI Coding 工作台中接入
ThoughtChain的实践。这里说的 Thought,不是把模型私有思维链直接暴露给用户,而是把"AI 正在调用什么工具、工具执行结果是什么"可视化出来,让生成过程更可理解、更可调试。
背景
AI 代码生成类产品有一个天然的问题:用户输入一句需求后,界面上经常会进入一段长时间的"等待中"。
如果只是显示一个 loading,用户不知道:
- AI 有没有真的开始工作;
- 它在读文件还是写文件;
- 某一步卡住是不是因为工具失败;
- 最终页面为什么变成了现在这样。
尤其是零代码 / AI Coding 场景里,AI 不是只返回一段文本,它还会调用工具:
writeFile:写入文件;modifyFile:修改文件;readFile:读取文件;readDir:读取目录;deleteFile:删除文件;exit:结束工具调用。
这些工具调用本身就是生成过程的一部分。如果完全不展示,用户会觉得 AI 像一个黑盒;如果全部混进聊天正文,又会污染最终回复。
所以我在项目里做了一个折中设计:
聊天气泡里只展示工具调用过程;普通状态不展示;页面产物交给右侧 iframe 预览。
先说结论:Thought 不等于私有思维链
很多人一听 Thought,就会想到"展示模型思维链"。但在真实产品里,这件事要非常谨慎。
我的处理方式是:
- 不展示模型内部不可见的完整 chain-of-thought;
- 只展示后端明确输出的、可审计的工具事件;
- 思考摘要如果后端有
reasoning_summary / thinking_summary可以单独展示; status / progress / artifact这类状态事件不进入聊天正文;- 页面生成结果由右侧预览区承担,而不是塞进 bubble。
也就是说,这里的 Thought 更像是:
AI Agent 执行轨迹的可视化,而不是模型私有推理过程的泄露。
整体架构
当前链路是这样的:
txt
Java 后端 /app/chat/gen/code
|
| SSE
v
createSseStream
|
v
parseChatStreamMessage
|
v
ChatToGenCodeStreamChunk
|
v
useWorkbenchChat 追加到 assistant message
|
v
BubbleList / AssistantMessageContent / ThoughtChain
前端真正关心的流式增量被收敛成三类:
ts
export interface ChatToGenCodeStreamChunk {
content?: string;
reasoningContent?: string;
thought?: AgentThought;
}
这三个字段分别对应:
| 字段 | 用途 | 展示位置 |
|---|---|---|
content |
AI 最终回复正文 | 普通 assistant bubble |
reasoningContent |
推理摘要,不是私有思维链 | ThoughtChain 的 reasoning 区 |
thought |
Agent 工具调用状态 | assistant bubble 内的工具调用 ThoughtChain |
注意,thought 现在只用于工具调用,不承载普通请求状态,也不承载模型私有思维链。
SSE 协议怎么设计
后端 SSE 不建议直接返回一堆文本。更合理的是每个事件都有明确的 type:
json
{"type":"answer","d":"你好!有什么我可以帮你的吗?"}
json
{"type":"thought","d":{"id":"call_1","type":"tool_call","status":"pending","title":"调用工具:writeFile","description":"正在调用 writeFile","toolName":"writeFile","input":{"relativeFilePath":"src/App.vue"}}}
json
{"type":"thought","d":{"id":"call_1","type":"tool_call","status":"success","title":"工具完成:writeFile","description":"writeFile 执行完成","toolName":"writeFile","output":"文件写入成功: src/App.vue"}}
json
{"type":"status","d":"正在调用 AI 生成服务,请稍候。"}
这里的关键点是:不是所有事件都应该显示在聊天正文里。
比如 status 只是请求生命周期提示,用户并不需要在最终 bubble 里看到:
txt
已接收请求,正在准备代码生成任务。
已完成用户身份、请求参数和应用权限校验。
正在调用 AI 生成服务,请稍候。
AI 输出完成,正在保存会话记录并处理生成产物。
这些内容如果被拼进普通 AI 回复,会非常吵。
所以前端协议适配层必须做明确分流。
工具事件的数据结构
前端统一把工具事件转成 AgentThought:
ts
export type AgentThoughtType = 'tool_call';
export type AgentThoughtStatus = 'pending' | 'success' | 'error';
export interface AgentThought {
id: string;
type: AgentThoughtType;
status: AgentThoughtStatus;
title: string;
description?: string;
toolName?: string;
toolLabel?: string;
input?: unknown;
output?: unknown;
createdAt?: string;
}
这套结构只表达工具调用,不表达 status / artifact。普通状态事件可以用于顶部 loading、toast 或直接忽略;页面产物应该走右侧预览区。
工具请求事件会变成:
ts
{
id: 'call_1',
type: 'tool_call',
status: 'pending',
title: '调用工具:writeFile',
description: '正在调用 writeFile',
toolName: 'writeFile',
input: { relativeFilePath: 'src/App.vue' },
}
工具执行完成事件会变成:
ts
{
id: 'call_1',
type: 'tool_call',
status: 'success',
title: '工具完成:writeFile',
description: 'writeFile 执行完成',
toolName: 'writeFile',
output: '文件写入成功: src/App.vue',
}
这里的 id 很关键。
同一个工具调用开始和结束应该用同一个 id,这样前端可以把"调用中"更新成"已完成",而不是堆两条重复事件。
流式追加时如何合并工具事件
在 useWorkbenchChat 里,我没有每次都简单 append,而是按 id 做 upsert:
ts
function mergeTraceEvents(
currentEvents: AgentMessage['events'] = [],
nextEvent: NonNullable<AgentMessage['events']>[number],
) {
const existingIndex = currentEvents.findIndex(event => event.id === nextEvent.id);
if (existingIndex < 0) {
return [...currentEvents, nextEvent];
}
return currentEvents.map((event, index) => {
return index === existingIndex ? { ...event, ...nextEvent } : event;
});
}
收到流式 chunk 时:
ts
replaceMessage(target, assistantKey, item => ({
...item,
loading: false,
content: `${item.content}${chunk.content ?? ''}`,
reasoningContent: `${item.reasoningContent ?? ''}${chunk.reasoningContent ?? ''}`,
events: chunk.event ? mergeTraceEvents(item.events, chunk.event) : item.events,
}));
这样 UI 上会表现为:

而不是:
txt
调用工具:writeFile
工具完成:writeFile
调用工具:writeFile
工具完成:writeFile
...
ThoughtChain 组件如何渲染
最终展示层封装成了一个 ChatThoughtChain.vue:
vue
<ThoughtChain
v-if="items.length"
class="chat-thought-chain"
:items="items"
:collapsible="collapsible"
size="small"
/>
items 来自工具事件:
ts
const items = computed(() => {
return visibleThoughts.value.map(thought => ({
key: thought.id,
icon: getThoughtIcon(thought),
title: thought.title,
description: thought.description,
status: thought.status,
content: formatThoughtDetail(thought)
? h(MarkdownRenderer, { content: `\`\`\`json\n${formatThoughtDetail(thought)}\n\`\`\`` })
: undefined,
}));
});
状态映射很简单:
ts
export type AgentThoughtStatus = 'pending' | 'success' | 'error';
图标也保持克制:
ts
function getThoughtIcon(thought: AgentThought) {
if (thought.status === 'pending') {
return h(LoadingOutlined);
}
return thought.status === 'success' ? h(ToolOutlined) : h(ClockCircleOutlined);
}
在 bubble 中的展示顺序
assistant 消息不是直接渲染 Markdown,而是拆成三层:
vue
<div class="assistant-message-content">
<ChatThoughtChain v-if="thoughts.length" :thoughts="thoughts" />
<ChatReasoningChain
v-if="reasoningContent"
:content="reasoningContent"
:active="reasoningActive"
/>
<MarkdownRenderer v-if="answerContent" :content="answerContent" />
</div>
顺序是:
- 工具调用过程;
- 思考摘要;
- 最终回答正文。
为什么工具调用放在回答前面?
因为用户最关心的是:
你刚才做了什么,然后才是你最终给我的结果。
但这个区域默认是可折叠的,不会一直抢占聊天空间。
后端需要怎么配合
后端不需要知道前端具体怎么渲染,但要保证 SSE 事件语义清晰。
比如 Java Controller 可以把底层流式消息映射成:
java
private ServerSentEvent<String> buildSseEventFromStreamChunk(String chunk) {
JSONObject object = JSONUtil.parseObj(chunk);
String streamType = object.getStr("type");
return switch (streamType) {
case "ai_response" -> buildSseDataEvent("answer", object.getStr("data", ""));
case "thinking" -> buildSseDataEvent("reasoning", object.getStr("data", ""));
case "tool_request" -> buildSseDataEvent("thought", buildToolThought(object, "pending"));
case "tool_executed" -> buildSseDataEvent("thought", buildToolThought(object, "success"));
default -> buildSseDataEvent("answer", object.getStr("content", chunk));
};
}
这里有一个经验教训:工具事件展示失败不能让整条生成失败。
之前后端出现过类似错误:
txt
Cannot invoke "BaseTool.generateToolExecutedResult(...)" because "tool" is null
原因是:
java
BaseTool tool = toolManager.getTool(toolName);
String result = tool.generateToolExecutedResult(jsonObject);
如果 toolName 没有注册,tool 就是 null,整个 SSE 流会被打断,前端收到 business-error。
正确做法是兜底:
java
private String buildToolExecutedDisplayResult(ToolExecutedMessage toolExecutedMessage) {
String fallbackResult = StrUtil.blankToDefault(toolExecutedMessage.getResult(), "工具执行完成");
String toolName = StrUtil.trim(toolExecutedMessage.getName());
BaseTool tool = toolManager.getTool(toolName);
if (tool == null) {
log.warn("未找到工具定义,直接使用原始工具结果: {}", toolName);
return fallbackResult;
}
try {
JSONObject arguments = StrUtil.isBlank(toolExecutedMessage.getArguments())
? new JSONObject()
: JSONUtil.parseObj(toolExecutedMessage.getArguments());
String formattedResult = tool.generateToolExecutedResult(arguments);
return StrUtil.blankToDefault(formattedResult, fallbackResult);
} catch (Exception error) {
log.warn("格式化工具执行结果失败,直接使用原始工具结果: {}", toolName, error);
return fallbackResult;
}
}
工具展示只是 UI 增强,不能影响主流程。
踩坑总结
1. 不要把 d 字段无脑当正文
很多 SSE 包装都会用:
json
{"type":"xxx","d":"..."}
如果前端 fallback 里直接取 d,就会把 status.d 也拼进正文。
解决方式是在 fallback 前显式忽略非正文事件。
2. Thought 不是越多越好
展示太多状态会让用户疲劳。
真正值得展示的是:
- AI 调用了什么工具;
- 工具是否成功;
- 必要时展开看参数和结果。
3. 工具事件要有稳定 id
tool_request 和 tool_executed 最好共享同一个 id。
这样前端可以更新同一条 Thought item,而不是不断追加重复记录。
4. 工具展示失败不能中断主流程
工具调用结果的格式化只是展示层增强。
如果格式化失败,应该 fallback 到原始 result,而不是抛异常让整个 SSE 失败。
小结
这次接入 Thought 的核心不是"展示更多东西",而是"展示正确的东西"。
最终我把事件分成了四类:
txt
answer -> AI 正文
reasoning -> 思考摘要
tool_* -> ThoughtChain 工具调用过程
这样做之后,AI 工作台的体验会更像一个真实的 Agent IDE:
- 用户能看到 AI 正在使用工具;
- 不会被系统状态刷屏;
- 最终回复保持干净;
- 页面产物在右侧完整展示;
- 出错时也能定位到具体工具或后端环节。

这就是我在项目里使用 Thought 的方式:不是展示私有思维链,而是把 Agent 的工具执行过程做成可理解、可折叠、可调试的用户界面。