先看一组真实的官方 output 价格(2026 年主流模型,每百万 token / MTok):

假设你的 Agent 每月产生 100 万 token 的工具调用输出,按官方汇率 ¥7.3 = $1 计算:

而通过 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 实测,国内华东节点):

社区反馈方面,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.0086.3%310
Claude Sonnet 4.5$15.00¥109.50¥15.0086.3%420
Gemini 2.5 Flash$2.50¥18.25¥2.5086.3%180
DeepSeek V3.2$0.42¥3.07¥0.4286.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_reasonlength 而非 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%,这笔钱拿去给团队加鸡腿不香吗?

👉 免费注册 HolySheep AI,获取首月赠额度