我是张大鹏,做了十多年人工智能,带过不少项目。说实话,最难的不是让AI生成正确的答案,是让答案以正确的方式呈现给用户 。最近Claude 3.7推出了extended thinking模式,OpenAI的o系列也在做类似的事情------让AI的推理过程可见。但我们在如意Agent里做这件事的时候,发现单纯的"显示思考过程"不够,用户真正需要的是信息分层。
一、传统AI输出的痛点
用过ChatGPT或Claude的都知道,AI回答问题的典型流程是:
- 你提了一个复杂问题
- 屏幕开始滚动,AI滔滔不绝
- 你盯着屏幕看了30秒,还没看到重点
- 最后发现答案藏在第8段,前面全是推理过程
这种"一镜到底"的输出方式,在简单问答时没问题。但在多轮工具调用、复杂推理的场景下,体验很糟糕:
| 场景 | 问题 | 用户感受 |
|---|---|---|
| 工具调用 | AI先思考用哪个工具,再调用,再分析结果,再决定下一步 | "到底在干嘛?进度多少了?" |
| 长推理 | 1000字的思考过程,最后结论只有50字 | "前面这么多字,就为了说这么点事?" |
| 多轮对话 | 历史上下文混在一起,新信息被淹没 | "好像之前说过这个,是不是重复了?" |
我们的核心需求很明确:让思考过程可见但不干扰,让结论突出但不突兀,让详细回答有序但不冗长。
二、三段式流式显示设计
基于上面的分析,我们设计了 thinking / summary / answer 三段式流式显示。
2.1 三段定义
| 段落 | 内容 | 显示策略 | 目的 |
|---|---|---|---|
| thinking | AI的推理过程、工具调用分析、策略调整 | 可折叠,默认收起 | 满足好奇心,不干扰阅读 |
| summary | 极简单行概括(<30字),包含关键结论 | 独立高亮显示 | 快速获取核心信息 |
| answer | 详细回答、代码示例、数据表格 | 主体展示 | 获取完整信息 |
2.2 消息协议设计
后端流式输出的消息格式:
python
class MessageBlock(BaseModel):
type: Literal["thinking", "summary", "answer"]
content: str
is_complete: bool = False
WebSocket推送时,每个block独立发送:
json
{"type": "thinking", "content": "正在分析项目结构...", "is_complete": false}
{"type": "thinking", "content": "发现3个相关模块,优先检查handler层", "is_complete": true}
{"type": "summary", "content": "问题根源在HTTP流重试逻辑,建议检查第47行", "is_complete": true}
{"type": "answer", "content": "详细分析如下...", "is_complete": false}
关键点:
- type字段让前端知道当前收到的是什么内容
- is_complete字段标记段落是否结束,用于UI状态更新
- 独立推送保证即使thinking很长,summary和answer也能及时到达
2.3 前端渲染策略
终端版(Rich)的实现:
python
from rich.live import Live
from rich.panel import Panel
from rich.markdown import Markdown
from rich.layout import Layout
class StreamingDisplay:
def __init__(self, console):
self.console = console
self.thinking = ""
self.summary = ""
self.answer = ""
self.show_thinking = False # 默认折叠
def update(self, block: MessageBlock):
if block.type == "thinking":
self.thinking = block.content
elif block.type == "summary":
self.summary = block.content
elif block.type == "answer":
self.answer += block.content
self._render()
def _render(self):
layout = Layout()
# summary 始终显示在最上方
if self.summary:
layout.split_column(
Layout(Panel(f"💡 {self.summary}", style="bold cyan")),
Layout(self._build_main_content())
)
else:
layout = self._build_main_content()
self.live.update(layout)
def _build_main_content(self):
# thinking 可折叠
if self.show_thinking and self.thinking:
thinking_panel = Panel(
self.thinking,
title="🔍 思考过程",
style="dim"
)
else:
thinking_panel = ""
# answer 主体
answer_panel = Panel(Markdown(self.answer), title="如意Agent")
return Layout(thinking_panel + answer_panel)
Web版(Vue3)的实现更简单,用v-if控制折叠:
vue
<template>
<div class="message-blocks">
<!-- summary 始终显示 -->
<div v-if="summary" class="summary-block">
💡 {{ summary }}
</div>
<!-- thinking 可折叠 -->
<div v-if="thinking" class="thinking-section">
<button @click="toggleThinking">
{{ showThinking ? '收起' : '查看思考过程' }}
</button>
<div v-show="showThinking" class="thinking-block">
{{ thinking }}
</div>
</div>
<!-- answer 主体 -->
<div class="answer-block" v-html="renderedAnswer"></div>
</div>
</template>
三、后端解析实现
三段式的核心难点在解析:LLM返回的是纯文本流,如何从中提取出thinking、summary、answer?
3.1 标签约定
我们在system prompt中要求LLM按固定格式输出:
<thinking>
分析当前情况...
决定使用哪个工具...
</thinking>
<summary>
极简单行概括(<30字)
</summary>
详细回答...
代码示例...
这个约定有几个好处:
- 结构化:标签明确,解析简单
- 向后兼容:不支持标签的LLM,内容会全部落入answer
- 可扩展:未来增加新段落类型,只需加新标签
3.2 流式解析器
解析器需要处理流式输入,不能等全部内容到达再解析:
python
import re
from typing import Generator
class StreamParser:
def __init__(self):
self.buffer = ""
self.current_block = None
def parse(self, text_stream: Generator[str, None, None]) -> Generator[MessageBlock, None, None]:
"""流式解析文本,实时产出MessageBlock"""
for chunk in text_stream:
self.buffer += chunk
# 检查是否完整捕获了一个标签块
for block_type, pattern in [
("thinking", r"<thinking>([