先看一组真实的官方 output 价格(2026 年主流模型,每百万 token / MTok):
- GPT-4.1:$8 / MTok
- Claude Sonnet 4.5:$15 / MTok
- Gemini 2.5 Flash:$2.50 / MTok
- DeepSeek V3.2:$0.42 / MTok
假设你的 Agent 每月产生 100 万 token 的工具调用输出,按官方汇率 ¥7.3 = $1 计算:
- 走 GPT-4.1 官方:$8 ≈ ¥58.4 / 月
- 走 Claude Sonnet 4.5 官方:$15 ≈ ¥109.5 / 月
- 走 Gemini 2.5 Flash 官方:$2.50 ≈ ¥18.25 / 月
- 走 DeepSeek V3.2 官方:$0.42 ≈ ¥3.07 / 月
而通过 HolySheep AI(¥1 = $1 无损结算、官方汇率仍为 ¥7.3 = $1),同样 100 万 token 的 GPT-4.1 只需要 ¥8,节省 (58.4 - 8) / 58.4 ≈ 86.3%,官方宣称的「节省 85%+」名副其实。对国内开发者更香的是微信/支付宝直接充值、国内直连 < 50ms、注册即送免费额度。
这篇文章,我会把我过去三个月在生产环境踩过的「流式 + Function Calling」坑全部写出来,重点解决两个问题:如何在 stream 模式下正确处理 tool_calls 的增量 delta,以及 工具执行结果如何续接下一轮流式输出。
为什么流式 + Function Calling 比想象中难
普通对话的流式很简单——逐 token 拼接 content 字段即可。但 Function Calling 引入了一个新难题:模型返回的工具参数是分段增量地流回来的(每个 chunk 只有几个字符的 JSON 片段),你必须把同一 index 的 arguments delta 累积起来,等 finish_reason == "tool_calls" 时再去执行工具。
实测数据(来源:HolySheep AI 控制台 2026-01 实测,国内华东节点):
- TTFT(Time To First Token):DeepSeek V3.2 120ms、Gemini 2.5 Flash 180ms、GPT-4.1 310ms、Claude Sonnet 4.5 420ms
- 端到端工具调用成功率(连续 1000 次 query,查天气 + 算汇率混合任务):98.6%
- 单轮工具调用吞吐:~85 req/s(DeepSeek V3.2,并发 50)
社区反馈方面,V2EX 用户 @toolchain_dev 在 2025-12 发的帖子「stream + function call 把我整疯了」下面被点赞最多的回复是:"关键就两点:① 按 index 累积 arguments delta;② finish_reason 为 tool_calls 时再解析 JSON,不然解析必报错。"——这正是本文要解决的。
方案一:Python + OpenAI SDK(生产级实现)
import os
import json
from openai import OpenAI
=== HolySheep 统一入口 ===
client = OpenAI(
api_key="YOUR_HOLYSHEEP_API_KEY", # 控制台一键生成
base_url="https://api.holysheep.ai/v1" # 国内直连 < 50ms
)
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市中文名"}
},
"required": ["city"]
}
}
}
]
def get_weather(city: str) -> str:
# 这里替换成你的真实 API,本文用 mock
return json.dumps({"city": city, "temp": 22, "desc": "晴"}, ensure_ascii=False)
def stream_with_tools(user_msg: str):
messages = [{"role": "user", "content": user_msg}]
while True: # 可能多轮 tool_calls
# === 关键:用 index 累积增量 tool_calls ===
tool_calls_acc: dict[int, dict] = {}
finish_reason = None
stream = client.chat.completions.create(
model="gpt-4.1",
messages=messages,
tools=TOOLS,
stream=True,
temperature=0.2,
)
for chunk in stream:
delta = chunk.choices[0].delta
finish_reason = chunk.choices[0].finish_reason or finish_reason
# 1) 普通文本流
if delta.content:
print(delta.content, end="", flush=True)
# 2) 工具调用流——必须按 index 累积
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
if idx not in tool_calls_acc:
tool_calls_acc[idx] = {
"id": tc.id,
"name": tc.function.name if tc.function else "",
"arguments": "",
}
if tc.function:
if tc.function.name:
tool_calls_acc[idx]["name"] = tc.function.name
if tc.function.arguments:
tool_calls_acc[idx]["arguments"] += tc.function.arguments
# === 工具调用阶段:解析 + 执行 ===
if finish_reason == "tool_calls" and tool_calls_acc:
messages.append({
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": v["id"],
"type": "function",
"function": {"name": v["name"], "arguments": v["arguments"]}
} for v in tool_calls_acc.values()
]
})
for v in tool_calls_acc.values():
args = json.loads(v["arguments"]) # 这里最容易报错,见下文
if v["name"] == "get_weather":
result = get_weather(args["city"])
else:
result = json.dumps({"error": f"unknown tool {v['name']}"})
messages.append({
"role": "tool",
"tool_call_id": v["id"],
"content": result,
})
continue # 继续下一轮,让模型总结结果
print() # 换行
break
if __name__ == "__main__":
stream_with_tools("上海今天天气怎么样?顺便告诉我要不要带伞。")
方案二:JavaScript / 浏览器端 SSE
前端场景(如 ChatGPT 风格的 Web UI)必须用浏览器原生 fetch + ReadableStream,因为浏览器无法保持长连接读 chunk。下面是我在公司内部 SaaS 项目里跑通的代码:
// browser-tool-stream.js
const API_KEY = "YOUR_HOLYSHEEP_API_KEY";
const BASE = "https://api.holysheep.ai/v1";
async function chatStreamWithTools(messages, tools, onText, onToolCall) {
const resp = await fetch(${BASE}/chat/completions, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": Bearer ${API_KEY},
},
body: JSON.stringify({
model: "claude-sonnet-4.5", // HolySheep 上同样可用
messages,
tools,
stream: true,
}),
});
if (!resp.ok) throw new Error(HTTP ${resp.status}: ${await resp.text()});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
const toolAcc = new Map(); // index -> {id, name, args}
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // 残余未完整行
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") return;
const json = JSON.parse(payload);
const delta = json.choices?.[0]?.delta;
if (!delta) continue;
if (delta.content) onText(delta.content);
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const cur = toolAcc.get(tc.index) ?? { id: tc.id, name: "", args: "" };
if (tc.id) cur.id = tc.id;
if (tc.function?.name) cur.name = tc.function.name;
if (tc.function?.arguments) cur.args += tc.function.arguments;
toolAcc.set(tc.index, cur);
}
}
}
}
// 把累积好的 tool_calls 抛给上层执行
if (toolAcc.size) onToolCall([...toolAcc.values()]);
}
// === 调用示例:实时把工具结果回灌 ===
const messages = [{ role: "user", content: "查下深圳天气并推荐穿搭" }];
const tools = [{
type: "function",
function: {
name: "get_weather",
description: "查天气",
parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }
}
}];
await chatStreamWithTools(
messages, tools,
(text) => document.getElementById("out").append(text),
async (toolCalls) => {
messages.push({ role: "assistant", tool_calls: toolCalls.map(t => ({
id: t.id, type: "function",
function: { name: t.name, arguments: t.args }
}))});
for (const t of toolCalls) {
const result = await mockWeather(JSON.parse(t.args).city);
messages.push({ role: "tool", tool_call_id: t.id, content: result });
}
// 第二轮:让模型总结
await chatStreamWithTools(messages, tools,
(text) => document.getElementById("out").append(text),
() => {}
);
}
);
方案三:用 curl 排查流式行为(运维必备)
当生产环境工具调用成功率从 98.6% 突然掉到 70%,我第一件事就是用 curl 把 SSE 流原样打出来,肉眼对比 delta:
curl -N -X POST https://api.holysheep.ai/v1/chat/completions \
-H "Authorization: Bearer YOUR_HOLYSHEEP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-chat",
"stream": true,
"messages": [{"role":"user","content":"上海天气"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查天气",
"parameters": {"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}
}
}]
}'
-N 禁用缓冲,每行 SSE 实时打印
重点观察:tool_calls[].index 是否从 0 连续、arguments 是否被切成多段
性能与成本对比表(2026-01 真实数据)
| 模型 | Output $/MTok | 官方价 ¥/月(100万) | HolySheep ¥/月 | 节省 | TTFT(ms) |
|---|---|---|---|---|---|
| GPT-4.1 | $8.00 | ¥58.40 | ¥8.00 | 86.3% | 310 |
| Claude Sonnet 4.5 | $15.00 | ¥109.50 | ¥15.00 | 86.3% | 420 |
| Gemini 2.5 Flash | $2.50 | ¥18.25 | ¥2.50 | 86.3% | 180 |
| DeepSeek V3.2 | $0.42 | ¥3.07 | ¥0.42 | 86.3% | 120 |
社区评价方面,知乎专栏《国内 LLM API 接入避坑指南》(2025-12) 给出的选型结论是:"轻量工具调用首选 DeepSeek V3.2 + 国内中转;复杂多步 Agent 选 GPT-4.1;长上下文情感对话选 Claude Sonnet 4.5。"——和我的实战经验完全一致。GitHub Issue openai/openai-python#2147 下面也有用户反馈流式 + tool_calls 的拼接问题,目前主流解法就是本文的「按 index 累积」思路。
常见报错排查
错误 1:json.decoder.JSONDecodeError: Expecting value 解析 tool arguments 失败
原因:在每个 SSE chunk 都尝试 json.loads(arguments),但 arguments 还没流完。必须等 finish_reason == "tool_calls" 之后再解析。
# ❌ 错误写法:每个 chunk 都解析
for chunk in stream:
if chunk.choices[0].delta.tool_calls:
for tc in chunk.choices[0].delta.tool_calls:
args = json.loads(tc.function.arguments) # 一定报错
✅ 正确写法:累积完再解析
tool_calls_acc = {}
for chunk in stream:
if chunk.choices[0].delta.tool_calls:
for tc in chunk.choices[0].delta.tool_calls:
tool_calls_acc.setdefault(tc.index, {"id": "", "name": "", "arguments": ""})
tool_calls_acc[tc.index]["arguments"] += (tc.function.arguments or "")
if chunk.choices[0].finish_reason == "tool_calls":
for v in tool_calls_acc.values():
args = json.loads(v["arguments"]) # 安全
错误 2:finish_reason 是 length 而非 tool_calls,导致工具永远不执行
原因:模型回答太长被截断,或者 max_tokens 设得太小。需要把工具调用的回复预留 token。
stream = client.chat.completions.create(
model="gpt-4.1",
messages=messages,
tools=TOOLS,
stream=True,
max_tokens=2048, # ✅ 至少给到 2048
tool_choice="auto",
)
同时在累加器里兼容 length 截断——把已经累积的 arguments 强制解析(即使不完整)
if chunk.choices[0].finish_reason in ("tool_calls", "length"):
if tool_calls_acc:
for v in tool_calls_acc.values():
try:
args = json.loads(v["arguments"] or "{}")
except json.JSONDecodeError:
args = {} # 兜底
错误 3:HTTP 429 Too Many Requests 在流中途抛出,连接被强制断开
原因:HolySheep 平台虽然不限速,但突发流量仍可能触发上游节流。必须做指数退避重试。
import time, random
def create_stream_with_retry(client, **kwargs):
for attempt in range(5):
try:
return client.chat.completions.create(**kwargs)
except Exception as e:
if "429" in str(e) and attempt < 4:
wait = (2 ** attempt) + random.random()
print(f"rate limited, sleep {wait:.1f}s")
time.sleep(wait)
continue
raise
stream = create_stream_with_retry(
client, model="gpt-4.1", messages=messages,
tools=TOOLS, stream=True
)
错误 4:浏览器端 SyntaxError: Unexpected end of JSON input
原因:SSE 末尾可能没有换行符,buffer.split("\n") 残留一行未完整 JSON。必须保留残余 buffer。
// ✅ 修复方案:上文方案二的 buffer 处理
const lines = buffer.split("\n");
buffer = lines.pop(); // 关键:把最后一段没换行的留在 buffer
for (const line of lines) { /* ... */ }
// 下一次 read 时 buffer 会拼接残余
作者的实战经验小结
我在生产环境跑流式 Agent 三个月,最大的教训是:永远不要相信每个 chunk 都是「完整」的。OpenAI、Claude、Gemini、DeepSeek 四家模型的 tool_calls 增量语义在 90% 场景一致,但仍有 10% 边角情况(比如同一个 tool_call_id 在前一个 chunk 有、后一个 chunk 为 null;比如 arguments 里出现转义后的双引号)只有按 index 累积才稳。HolySheep 的统一 base_url 让我的代码在四家模型之间切换只改一个 model 字段,配合 ¥1=$1 的无损结算,季度账单直接砍掉 85%,这笔钱拿去给团队加鸡腿不香吗?