Agent 流式输出 —— 告别等待,让 LLM 边想边说

18 阅读5分钟

前置阅读:一个最简化的 AI Agent 核心实现 | Agent Skill 渐进式加载 | Agent 多轮对话循环 核心增量:stream_llm_call()(~80 行)+ run_agent_with_trace 调用点替换


一、问题:Agent 沉默了太久

前几篇文章构建的 Agent 有一个共同的体验问题——用户要等 LLM 完整生成完才能看到任何输出

用户: "帮我分析一下这段代码的性能瓶颈"
Agent: [沉默 8 秒...]
       "这段代码主要存在以下性能瓶颈..."   ← 一次性吐出全部内容

对于短回答这不算什么,但面对生成长分析报告或复杂推理时,用户只能盯着空白屏幕等。更重要的是,当 LLM 决定调用工具时,用户完全不知道 Agent 在想什么、在做什么——体验上就像一个黑盒。

流式输出要解决的就是这个问题:让 LLM 像人一样"边想边说",同时实时展示工具调用的决策过程。


二、核心原理:SSE 逐块推送

OpenAI 兼容 API 在请求中设置 stream=True 后,响应会以 SSE(Server-Sent Events)格式逐块推送:

非流式                          流式
───────                        ──────
POST → 等待 → 完整响应           POST → chunk1 → chunk2 → ... → [DONE]
  │                                │
  └── 8 秒后拿到全文                ├── 0.2s: "这段"
                                   ├── 0.4s: "代码"
                                   ├── 0.6s: "主要"
                                   └── ... 逐字出现

每个 chunk 只包含增量内容:

chunkdelta.contentdelta.tool_calls
1"这段"null
2"代码"null
.........
Nnull[{index: 0, function: {name: "search_web"}}]

关键:同一个 index 的 tool_call 可能跨越多个 chunk——name 在一个 chunk、arguments 在后续多个 chunk 中分批到达,需要手动拼接。


三、实现:stream_llm_call

def stream_llm_call(client, model, messages, tools):
    response = client.chat.completions.create(
        model=model, messages=messages, tools=tools,
        tool_choice="auto", stream=True,           # ← 关键
    )

    content_chunks = []
    tool_call_chunks = {}    # index → {id, name, arguments}
    shown_tool_names = set() # 已打印过的 tool 名

    print("🧠 ", end="", flush=True)

    for chunk in response:
        delta = chunk.choices[0].delta if chunk.choices else None
        if delta is None:
            continue

        # ── 文本:边收边打印 ──
        if delta.content:
            print(delta.content, end="", flush=True)
            content_chunks.append(delta.content)

        # ── tool_calls:实时显示 ──
        if delta.tool_calls:
            for tc_delta in delta.tool_calls:
                idx = tc_delta.index
                if idx not in tool_call_chunks:
                    tool_call_chunks[idx] = {"id": "", "name": "", "arguments": ""}
                if tc_delta.id:
                    tool_call_chunks[idx]["id"] = tc_delta.id
                if tc_delta.function.name:
                    tool_call_chunks[idx]["name"] = tc_delta.function.name
                    # 一旦拿到 name 就立刻打印
                    if idx not in shown_tool_names:
                        print(f"\n  🔧 调用 {tc_delta.function.name}", end="")
                        shown_tool_names.add(idx)
                if tc_delta.function.arguments:
                    tool_call_chunks[idx]["arguments"] += tc_delta.function.arguments

    # ── 组装返回 ──
    if tool_call_chunks:
        tc_list = []
        for idx in sorted(tool_call_chunks.keys()):
            tc = tool_call_chunks[idx]
            tc_list.append({
                "id": tc["id"], "type": "function",
                "function": {"name": tc["name"], "arguments": tc["arguments"]},
            })
            args_preview = tc["arguments"][:60] + ("..." if len(tc["arguments"]) > 60 else "")
            if args_preview:
                print(f"({args_preview})")
        return None, tc_list
    else:
        print()
        return "".join(content_chunks), None

返回值设计

场景返回 content返回 tool_calls
LLM 直接回答"完整文本"None
LLM 要调工具None[{id, function: {name, arguments}}]

这种元组返回让调用方可以用简单的 if tool_calls is None 判断该走回答分支还是工具分支。


四、主循环集成:换一行调用

run_agent_with_trace 中,原来的非流式调用只需要替换一行:

# 之前(非流式)
response = client.chat.completions.create(model=model, messages=messages, tools=active_tools)
msg = response.choices[0].message
if not msg.tool_calls:
    return msg.content
...

# 之后(流式)
content, tool_calls = stream_llm_call(client, model, messages, active_tools)

if tool_calls is None:             # 纯文本答案
    return content

# 手动构造 assistant 消息(流式没有完整 msg 对象)
assistant_msg = {
    "role": "assistant",
    "content": content,
    "tool_calls": tool_calls,
}
messages.append(assistant_msg)

for tc in tool_calls:
    result = active_tool_map[tc["function"]["name"]](**json.loads(tc["function"]["arguments"]))
    messages.append({"role": "tool", "tool_call_id": tc["id"], "content": str(result)})

关键差异:流式没有返回完整的 ChatCompletionMessage 对象,需要手动组装 assistant_msg。代码量多了一点,但换来了用户可以看到 Agent "思考"的过程。


五、流式与非流式的对比

flowchart LR
    subgraph NL[非流式调用]
        N1[发送请求] --> N2[等待 5-15s]
        N2 --> N3[拿到完整响应]
        N3 --> N4[一次性打印]
    end

    subgraph SL[流式调用]
        S1[发送请求] --> S2[逐块接收]
        S2 --> S3[边收边打印]
        S3 --> S4[实时展示工具调用]
    end
维度非流式流式
首字延迟5-15s< 0.5s
用户体验空白等待逐字输出,像人在打字
工具调用可见性不可见实时显示 🔧 调用 xxx
代码复杂度简单需处理 chunk 拼接和 tool_call 分片
Token 消耗完全相同完全相同(流式不影响 token 计费)
Trace 兼容性有完整 response 对象需手动估算 token 数

六、chunk 拼接的陷阱

流式处理中最大的坑是 tool_call 的分片到达。OpenAI 的流式协议中,一个 tool_call 的信息可能分散在多个 chunk 中:

chunk 1:  delta.tool_calls = [{index: 0, id: "call_abc"}]
chunk 2:  delta.tool_calls = [{index: 0, function: {name: "search_web"}}]
chunk 3:  delta.tool_calls = [{index: 0, function: {arguments: '{"qu'}}]
chunk 4:  delta.tool_calls = [{index: 0, function: {arguments: 'ery":"北京"}'}}]

所以不能直接 tool_calls.append(chunk),而要以 index 为 key 做累加:

tool_call_chunks = {}  # index → 累加缓冲

for chunk in response:
    for tc_delta in delta.tool_calls:
        idx = tc_delta.index
        if idx not in tool_call_chunks:
            tool_call_chunks[idx] = {"id": "", "name": "", "arguments": ""}
        if tc_delta.id:
            tool_call_chunks[idx]["id"] = tc_delta.id
        if tc_delta.function.name:
            tool_call_chunks[idx]["name"] = tc_delta.function.name
        if tc_delta.function.arguments:
            tool_call_chunks[idx]["arguments"] += tc_delta.function.arguments

stream_options 不开启时,最后一个 chunk 会用空字符串标识结束。收到后组装最终的 tool_calls 列表。


七、流式输出的完整时序

sequenceDiagram
    participant U as 用户
    participant CL as chat_loop
    participant RA as run_agent_with_trace
    participant SL as stream_llm_call
    participant API as LLM API
    participant TM as tool_map

    U->>CL: 输入问题
    CL->>RA: run_agent_with_trace(...)
    RA->>SL: stream_llm_call(messages, tools)
    SL->>API: POST (stream=True)

    loop 流式接收
        API-->>SL: chunk 1: &#34;这段&#34;
        SL-->>U: print(&#34;这段&#34;)
        API-->>SL: chunk 2: &#34;代码&#34;
        SL-->>U: print(&#34;代码&#34;)
        API-->>SL: chunk N: tool_call name
        SL-->>U: print(&#34;🔧 调用 search_web&#34;)
    end

    SL-->>RA: return (None, tool_calls)
    RA->>TM: search_web(&#34;北京天气&#34;)
    TM-->>RA: &#34;北京晴,25°C&#34;
    RA->>SL: stream_llm_call(新 messages)
    SL->>API: POST (stream=True)
    API-->>SL: &#34;北京今天晴天...&#34;
    SL-->>RA: return (&#34;北京今天晴天,25°C&#34;, None)
    RA-->>CL: return &#34;北京今天晴天,25°C&#34;
    CL-->>U: Agent: 北京今天晴天,25°C

用户在输入问题后约 0.5 秒就能看到第一个字,工具调用过程也实时可见——不再是一个黑盒。


八、设计精要

8.1 返回值是二元组而非对象

stream_llm_call 返回 (content, tool_calls) 而不是试图构造一个假的 Message 对象。原因很简单:流式响应没有原生的 message 概念,强行模拟会增加维护成本且误导调用方。二元组语义清晰——要么是文本答案,要么是工具调用列表,互斥。

8.2 工具名优先显示

一旦收到 function.name 就立刻打印 🔧 调用 xxx,不等到 arguments 收完。这个细节让用户能尽早知道 Agent 的意图:

🧠 这个问题需要查资料才能回答
  🔧 调用 search_web                  ← Name 一到就显示
  🔧 调用 search_web({"query":"北京天气"})  ← Arguments 收完后补充

8.3 Trace 的妥协

非流式下 tracer.log_llm_call(messages, response, elapsed) 可以直接从 response.usage 拿精确的 token 数。流式下这个信息丢失了(usage 字段通常只在最后一个 chunk 出现,且不是所有 Provider 都支持),所以用 ctx.count_tokens + len(ctx.encoder.encode(...)) 估算。

8.4 与前三层的零侵入

stream_llm_call 替换的只是 run_agent_with_trace 中"调用 LLM 并解析响应"那一段。SkillManager、AgentTracer、ContextManager、PersistenceManager 完全不需要修改。chat_loop 更是无感——它看到的仍然是 run_agent_with_trace 的字符串返回值。


九、完整演进路径

Phase 1: 基础 Agent               (~50 行)
  run_agent()  单次问答,非流式

Phase 2: 可观测 + 压缩 + 持久化     (+~200 行)
  run_agent_with_trace()  非流式
  AgentTracer + ContextManager + PersistenceManager

Phase 3: Skill 渐进加载            (+~150 行)
  SkillManager  按需激活工具

Phase 4: 多轮对话循环              (+~120 行)
  chat_loop()  交互式 REPL

Phase 5: 流式输出                  (+~80 行)   本文
  stream_llm_call()  边收边打印
  工具调用实时可见

每一步只改一个调用点或新增一个函数,前序代码不受影响。


推荐学习资源

  • 从零开始打造一个AI Agent CLI — 手把手带你构建一个完整的 AI Agent 命令行工具,从工具调用、对话管理到发布上线的全流程实战。
  • AI Agents 开发实践 — 涵盖 Agent 架构设计、记忆系统、多 Agent 协作等核心主题的实战教程,适合想深入 Agent 开发的工程师。
  • AI 全栈编程生存指南 — AI 时代的全栈开发者生存法则,从 AI 辅助编程到底层原理,帮你建立不可替代的技术壁垒。