在 AI 代码生成项目里接入 Thought:别展示“玄学思维链”,只展示用户真正关心的工具调用

在 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>

顺序是:

  1. 工具调用过程;
  2. 思考摘要;
  3. 最终回答正文。

为什么工具调用放在回答前面?

因为用户最关心的是:

你刚才做了什么,然后才是你最终给我的结果。

但这个区域默认是可折叠的,不会一直抢占聊天空间。

后端需要怎么配合

后端不需要知道前端具体怎么渲染,但要保证 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_requesttool_executed 最好共享同一个 id

这样前端可以更新同一条 Thought item,而不是不断追加重复记录。

4. 工具展示失败不能中断主流程

工具调用结果的格式化只是展示层增强。

如果格式化失败,应该 fallback 到原始 result,而不是抛异常让整个 SSE 失败。

小结

这次接入 Thought 的核心不是"展示更多东西",而是"展示正确的东西"。

最终我把事件分成了四类:

txt 复制代码
answer      -> AI 正文
reasoning   -> 思考摘要
tool_*      -> ThoughtChain 工具调用过程

这样做之后,AI 工作台的体验会更像一个真实的 Agent IDE:

  • 用户能看到 AI 正在使用工具;
  • 不会被系统状态刷屏;
  • 最终回复保持干净;
  • 页面产物在右侧完整展示;
  • 出错时也能定位到具体工具或后端环节。

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

相关推荐
艾醒(AiXing-w)1 小时前
LangChain 1.0 入门(二):LangChain 全模型标准化接入最佳实践(小白参数详解版)
前端·javascript·langchain
计算机魔术师2 小时前
我看了 Hugging Face 的 Daily Papers,发现这件事
前端
yu俞娥宝3 小时前
Codex官网前端可抄吗?技术拆解、风险边界与合规借鉴方案
前端
捧 花3 小时前
FastAPI 基础语法:从一个完整接口理解 Web API 的设计
前端·python·fastapi·middleware
Hilaku3 小时前
一行 CSS 新特性干掉 20 行 JavaScript ?
前端·javascript·程序员
JarvanMo3 小时前
AI 写代码暴增 161 倍,移动开发有没有变得更差?
前端
梦曦i3 小时前
RouterLink v2.5.0:H5端原生能力全面回归
前端·uni-app
小林ixn4 小时前
React + Zustand + JWT:从零实现登录鉴权与请求拦截
前端·react.js·前端框架
葡萄城技术团队4 小时前
下拉多选类型单元格:在 SpreadJS 里接入 xm\-select
前端