预计阅读 6 分钟

Function Calling:让 LLM 调真实工具

谁该读这一篇? 要做 agent / 工具调用 / OpenAI-compatible function call 的开发者。 前置阅读: 03-json-schema.md01-dsl-walkthrough.md耗时: 30 分钟 学完能: 1. 用 OpenAI 兼容 API 让 SGLang 模型选 tool; 2. 解释 function call parser 是怎么实时识别 tool call 的; 3. 区分流式 vs 非流式 tool 调用; 4. 知道 SGLang 支持哪些模型的 tool call 格式; 5. 调试 "tool call 解析失败" 类问题。


1. 一个例子

from openai import OpenAI
import json

client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY")

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"}
            },
            "required": ["city"]
        }
    }
}]

resp = client.chat.completions.create(
    model="Qwen/Qwen2.5-7B-Instruct",
    messages=[{"role": "user", "content": "今天上海天气?"}],
    tools=tools,
    tool_choice="auto",
)

msg = resp.choices[0].message
if msg.tool_calls:
    for call in msg.tool_calls:
        name = call.function.name
        args = json.loads(call.function.arguments)
        print(f"调用 {name}({args})")

启动时:

python -m sglang.launch_server \
    --model-path Qwen/Qwen2.5-7B-Instruct \
    --tool-call-parser qwen25

--tool-call-parser 指定 parser,对应不同模型的 tool call 格式。


2. 不同模型的 tool call 格式

模型厂商各家有自己的 tool 表示法:

模型 格式
Llama-3.1 <\|python_tag\|>...<\|eot_id\|> 或 JSON
Qwen2.5 <tool_call>{"name": "...", "arguments": {...}}</tool_call>
Qwen3 XML-like + JSON arguments
DeepSeek-V3 / V3.1 / V3.2 自家格式(有差异)
Hermes / Mistral 直接 JSON
GPT-OSS 类 Llama
GLM-4 / 4.7 XML 标记
Gemma 4 类 Hermes
Hunyuan / InternLM / Kimi K2 / Pixtral 各自专属

SGLang 在 srt/function_call/ 下为每个模型实现一个 detector:

function_call/
├── function_call_parser.py       ← 总入口
├── base_format_detector.py       ← 抽象基类
├── core_types.py                 ← 数据结构
├── qwen25_detector.py
├── qwen3_detector.py
├── deepseekv3_detector.py
├── deepseekv31_detector.py
├── deepseekv4_detector.py
├── hermes_detector.py
├── gpt_oss_detector.py
├── glm4_moe_detector.py
├── glm47_moe_detector.py
├── gemma4_detector.py
├── hunyuan_detector.py
├── internlm_detector.py
├── kimi_k2_detector.py
├── pixtral_detector.py
└── ...

启动时 --tool-call-parser 选对应的。


3. Parser 工作原理

Tool call parser 是个状态机,边接收 token 边判断"现在是不是在 tool call 里"。

class BaseFormatDetector:
    def parse_streaming(self, new_text):
        """逐 chunk 调用,返回:
           - 普通文本 chunk(继续给客户端)
           - 一个完整 tool call(触发 tool)
           - 还需要更多输入(继续等)
        """

伪代码(以 Qwen2.5 为例):

TOOL_START = "<tool_call>"
TOOL_END = "</tool_call>"

class Qwen25Detector:
    def __init__(self):
        self.buffer = ""
        self.in_tool = False

    def parse_streaming(self, new_text):
        self.buffer += new_text
        if not self.in_tool:
            idx = self.buffer.find(TOOL_START)
            if idx == -1:
                # 没看到 tool start, 看似纯文本
                # 但要小心 "<tool_ca" 这种半截,不能输出
                safe_len = len(self.buffer) - (len(TOOL_START) - 1)
                emit = self.buffer[:safe_len]
                self.buffer = self.buffer[safe_len:]
                return TextChunk(emit)
            else:
                # 看到了 tool start
                text_before = self.buffer[:idx]
                self.buffer = self.buffer[idx + len(TOOL_START):]
                self.in_tool = True
                return TextChunk(text_before)
        else:
            idx = self.buffer.find(TOOL_END)
            if idx == -1:
                return None              # 还在 tool 内部,等更多
            json_str = self.buffer[:idx]
            self.buffer = self.buffer[idx + len(TOOL_END):]
            self.in_tool = False
            return ToolCall(json.loads(json_str))

4. 流式 vs 非流式

4.1 非流式

模型整段输出完才解析:

text = full_response_text
calls = parser.parse_full(text)

简单可靠。客户端等完整响应。

4.2 流式

边接收边解析:

for chunk in stream:
    result = parser.parse_streaming(chunk.delta.content)
    if isinstance(result, ToolCall):
        execute_tool(result)
    elif isinstance(result, TextChunk):
        yield_to_client(result)

OpenAI API 兼容时 SGLang 给客户端的 chunk 是混合的:普通文本 chunk + tool call chunk。客户端按 OpenAI 协议处理。


5. 受约束 + Function Call 组合

为了避免模型乱编 tool name 或参数:

# 给 tool name 用 enum 约束(DSL)
@sgl.function
def call_tool(s, question, tools):
    s += sgl.user(question)
    s += sgl.assistant_begin()
    s += "<tool_call>{\"name\": \""
    s += sgl.select("name", choices=[t["function"]["name"] for t in tools])
    s += "\", \"arguments\": "
    # 根据选中的 name 取对应 schema
    selected_schema = next(t for t in tools if t["function"]["name"] == s["name"])
    s += sgl.gen("args", json_schema=json.dumps(selected_schema["function"]["parameters"]))
    s += "}</tool_call>"
    s += sgl.assistant_end()

DSL 把 select + json_schema 组合用,从协议层面杜绝错误。 OpenAI API 路径默认不开 schema 约束(信任模型自己输出),出错率比 DSL 高。


6. 多步 tool 调用(agent loop)

messages = [{"role": "user", "content": "查上海天气,把温度乘 2"}]
while True:
    resp = client.chat.completions.create(
        model="...", messages=messages, tools=tools, tool_choice="auto"
    )
    msg = resp.choices[0].message
    if not msg.tool_calls:
        print(msg.content)
        break
    messages.append(msg)
    for call in msg.tool_calls:
        result = execute(call.function.name, json.loads(call.function.arguments))
        messages.append({
            "role": "tool", "tool_call_id": call.id, "content": result
        })

每轮 SGLang runtime 都 cache 之前的 messages 上下文 → 多步 agent 收益巨大。 RadixCache 命中率在 agent 场景常 > 95%。


7. 排障 cheatsheet

现象 排查
Tool call 没识别 parser 不匹配模型;改 --tool-call-parser
arguments JSON 错 模型没遵循 schema;试 DSL 强约束
输出混乱 chat_template 错;输入 messages 格式
流式 chunk 卡顿 parser 等待 close tag;正常现象
Tool name 模型编 select 受限选择

8. 监控指标

sglang:tool_call_total{name="...", ...}
sglang:tool_call_parse_failure_total
sglang:tool_call_argument_invalid_total

parse_failure_rate < 1% 是健康范围。


9. 实战 tips

  1. Tool 数量超过 10:模型容易选错;考虑 hierarchical(先选类别再选具体 tool)。
  2. Tool 描述要简洁明确:模型靠 description 判断用不用;写得啰嗦反而模糊。
  3. arguments 字段加 description:让模型知道每个参数干啥。
  4. 错误参数返回友好 message:tool 报错时返回 "格式错误,请用 ...",模型能修正。
  5. 限制 max_steps:agent loop 必加上限,防止死循环。

10. 小结

  • Function calling = 受约束输出 + parser 解析 + 状态化迭代。
  • SGLang 为各家模型实现专属 parser(srt/function_call/)。
  • DSL 路径可以加约束让 tool name / 参数 100% 正确。
  • 多步 agent 在 SGLang 上 cache 命中率高,性能优势明显。
  • 监控 parse_failure_rate。

11. 自检

  1. SGLang 怎么知道用哪种 tool 格式解析?
答案 靠启动参数 `--tool-call-parser ` 显式指定(如 `qwen25` / `deepseekv3` / `hermes` / `llama4` 等)。 不同模型训练时教了不同的 tool 输出格式(Qwen2.5 用 `...`,Llama-3.1 用 `<|python_tag|>...<|eot_id|>`,Hermes 直接 JSON 等)。SGLang 在 [`srt/function_call/`](../sglang/python/sglang/srt/function_call/) 为每个模型实现专属 detector。 parser 选错的话:模型输出的标签 / 边界识别不出来,输出当作普通文本流回客户端,`tool_calls` 字段为空。生产部署一定要在 README 写清楚模型 ↔ parser 对应表。
  1. 流式 parser 为什么要 buffer?
答案 tool 边界标签可能横跨多 chunk。 例:模型吐 token 流 `"", "{\"name\":...}", ...`——单看 chunk 1 `"` 才确认。 实现:维护 `self.buffer` 累积字节;找 start_tag 时 buffer 末尾保留 `len(start_tag) - 1` 字节(保留半截 tag 等下次拼齐);找 end_tag 同理。 副作用:流式输出有 N-1 个 token 的 buffer 延迟(N = start_tag 长度),用户感知短暂卡顿。优化:在确认不可能是 tag 时尽快 flush 安全部分。
  1. DSL + select 怎么强制 tool name 正确?
答案 关键代码: ```python s += "{\"name\": \"" s += sgl.select("name", choices=[t["name"] for t in tools]) s += "\", \"arguments\": " selected = next(t for t in tools if t["name"] == s["name"]) s += sgl.gen("args", json_schema=json.dumps(selected["parameters"])) s += "}" ``` 原理: (a) `sgl.select` 物理上不允许生成 choices 外的内容(不调采样器,直接 logprob 比较选最大); (b) 选定 name 后再动态选对应的 parameter schema 走 json_schema 严格输出 arguments; (c) 整段 `` 包裹用 DSL 字符串拼接而不是让模型生成——零错位风险。 纯 OpenAI function_call 路径只能信任模型自己输出符合规范,hallucinate tool name 是常见 bug;DSL 路径协议层杜绝。
  1. 多步 agent 在 SGLang 上比 vLLM 快的原因?
答案 每一步 agent loop 都附带累积 messages 上下文,第 N 步的 prompt = 前 N-1 步全部内容拼接 + 新 user/system。 SGLang RadixCache 把前 N-1 步的 KV 完整保留(lock_ref 维持),第 N 步 `match_prefix` 命中率 95%+,只 prefill 末尾新增几十 token。 vLLM v1 也有 prefix cache,但 block hash 表是 16 token 整块匹配,agent 长上下文 + 多变 user input 的组合下命中率仅 60-70%。 实测多步 agent SGLang TTFT 比 vLLM 低 50-70%,单步 TPOT 接近。 配合 DSL 的 `s += sgl.gen(...)` 多轮在同 ProgramState 累积,命中确定性 100%;纯 OpenAI 客户端循环依赖 hash 盲匹配,命中率有方差。
  1. tool 数量过多怎么处理?
答案 tool > 10 后模型选错概率显著上升,建议: (1) **Hierarchical routing**:先用一次 LLM 选 tool **类别**(5-10 类),再选具体 tool;总分支可控。 (2) **RAG-style retrieval**:业务侧用 embedding 把 user query 与 tool description 匹配,只把 top-5 tool 喂给 LLM。 (3) **Tool 合并**:相似 tool 合并为一个,用 `mode` 参数区分。 (4) **DSL `select` 显式枚举**:哪怕 100 个 tool,DSL 路径用 `sgl.select(choices=[...100个])` 也能正确选(运行时跑一次 forward 算 100 个 logprob,比 free-form gen 快);只是 tool name 长度有限制。 (5) **降低成本**:tool description 加 `priority` 字段,常用 tool 文字精简。 监控 `parse_failure_rate`,> 1% 就 hierarchical。

12. 下一步

上游源码:sglang/python/sglang/srt/function_call/