前置阅读:一个最简化的 AI Agent 核心实现 | Agent Skill 渐进式加载 | Agent 多轮对话循环 核心增量:
stream_llm_call()(~80 行)+run_agent_with_trace调用点替换
一、问题:Agent 沉默了太久
前几篇文章构建的 Agent 有一个共同的体验问题------用户要等 LLM 完整生成完才能看到任何输出。
makefile
用户: "帮我分析一下这段代码的性能瓶颈"
Agent: [沉默 8 秒...]
"这段代码主要存在以下性能瓶颈..." ← 一次性吐出全部内容
对于短回答这不算什么,但面对生成长分析报告或复杂推理时,用户只能盯着空白屏幕等。更重要的是,当 LLM 决定调用工具时,用户完全不知道 Agent 在想什么、在做什么------体验上就像一个黑盒。
流式输出要解决的就是这个问题:让 LLM 像人一样"边想边说",同时实时展示工具调用的决策过程。
二、核心原理:SSE 逐块推送
OpenAI 兼容 API 在请求中设置 stream=True 后,响应会以 SSE(Server-Sent Events)格式逐块推送:
css
非流式 流式
─────── ──────
POST → 等待 → 完整响应 POST → chunk1 → chunk2 → ... → [DONE]
│ │
└── 8 秒后拿到全文 ├── 0.2s: "这段"
├── 0.4s: "代码"
├── 0.6s: "主要"
└── ... 逐字出现
每个 chunk 只包含增量内容:
| chunk | delta.content | delta.tool_calls |
|---|---|---|
| 1 | "这段" |
null |
| 2 | "代码" |
null |
| ... | ... | ... |
| N | null |
[{index: 0, function: {name: "search_web"}}] |
关键:同一个 index 的 tool_call 可能跨越多个 chunk------name 在一个 chunk、arguments 在后续多个 chunk 中分批到达,需要手动拼接。
三、实现:stream_llm_call
python
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 中,原来的非流式调用只需要替换一行:
python
# 之前(非流式)
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 "思考"的过程。
五、流式与非流式的对比
| 维度 | 非流式 | 流式 |
|---|---|---|
| 首字延迟 | 5-15s | < 0.5s |
| 用户体验 | 空白等待 | 逐字输出,像人在打字 |
| 工具调用可见性 | 不可见 | 实时显示 🔧 调用 xxx |
| 代码复杂度 | 简单 | 需处理 chunk 拼接和 tool_call 分片 |
| Token 消耗 | 完全相同 | 完全相同(流式不影响 token 计费) |
| Trace 兼容性 | 有完整 response 对象 | 需手动估算 token 数 |
六、chunk 拼接的陷阱
流式处理中最大的坑是 tool_call 的分片到达。OpenAI 的流式协议中,一个 tool_call 的信息可能分散在多个 chunk 中:
css
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 做累加:
python
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 列表。
七、流式输出的完整时序
用户在输入问题后约 0.5 秒就能看到第一个字,工具调用过程也实时可见------不再是一个黑盒。
八、设计精要
8.1 返回值是二元组而非对象
stream_llm_call 返回 (content, tool_calls) 而不是试图构造一个假的 Message 对象。原因很简单:流式响应没有原生的 message 概念,强行模拟会增加维护成本且误导调用方。二元组语义清晰------要么是文本答案,要么是工具调用列表,互斥。
8.2 工具名优先显示
一旦收到 function.name 就立刻打印 🔧 调用 xxx,不等到 arguments 收完。这个细节让用户能尽早知道 Agent 的意图:
json
🧠 这个问题需要查资料才能回答
🔧 调用 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 的字符串返回值。
九、完整演进路径
yaml
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 辅助编程到底层原理,帮你建立不可替代的技术壁垒。