# 工具调用范式②:原生 function-calling(结构化 tool_calls)

← 对照卡索引
🎯 原生 FC 把工具调用从"文本 + 正则"提升为"结构化 + schema 校验",从根上消除了 格式飘移与 path/file_path 不一致。它应当作主车道,react-over-text 退为 fallback。

### 📖 教科书概念

现代 LLM API(OpenAI / Anthropic / 兼容协议)原生支持 function calling: 把可用工具以 JSON Schema 形式声明给模型,模型在需要时返回一个**结构化**的 tool_calls 对象(工具名 + 已按 schema 校验的参数),而不是一段自由文本。 平台执行完工具后,以 role=tool 的消息把结果回灌,继续下一轮。整个过程里 "调用"是 API 协议的一等公民,参数有结构、可校验,不存在"解析模型散文"这一步。

### 💻 平台源码落点

`lambdagent/src/lambdagent/fromconfig/compiler.py` · _run_fc_loop · L778-878

    778def _run_fc_loop(provider, system_prompt: str, user_input: str, tools: Dict,779                 tools_schema: list, *, max_steps: int, tool_timeout: int,780                 on_step=None, cancel=None, ctx=None,781                 enforce_tool: str = "", enforce_min: int = 0) -> str:782    """原生 function-calling 的 ReAct 循环。用结构化 tool_calls 直接执行,不解析文本——783    根治 0 执行/格式飘/路径别名类 bug。见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md。784    复用 _timeout_call 工具执行 + on_step(StepEvent);per-tool 写 ctx.trace(可观测);785    支持 enforceLoop 计数模式(terminate 前 enforce_tool 须调够 enforce_min 次)。"""786    try:787        from lambdagent.agentruntime.react_engine import (788            StepEvent, STEP_THINK, STEP_TOOL_CALL, STEP_TOOL_RESULT)789    except ImportError:790        StepEvent = None791792    def _emit(etype, step, content, tool=""):793        if on_step and StepEvent is not None:794            try:795                on_step(StepEvent(type=etype, step=step, content=str(content)[:2000], tool=tool))796            except Exception:797                pass798799    import time as _time800    messages = [{"role": "system", "content": system_prompt},801                {"role": "user", "content": user_input}]802    tool_counts: Dict[str, int] = {}803    final = ""804    for step in range(max_steps):805        if cancel is not None and cancel.is_cancelled():806            raise cancel.CancelledRun()807        resp = provider.chat_with_tools(messages, tools_schema)808        content = resp.get("content")809        tcs = resp.get("tool_calls") or []810        if content:811            _emit(STEP_THINK, step, content)812        if not tcs:813            final = content or final814            break815        messages.append({"role": "assistant", "content": content or "",816            "tool_calls": [{"id": tc["id"] or f"call_{step}_{i}", "type": "function",817                            "function": {"name": tc["name"],818                                         "arguments": json.dumps(tc["arguments"], ensure_ascii=False)}}819                           for i, tc in enumerate(tcs)]})820        terminated = False821        for i, tc in enumerate(tcs):822            name, args = tc["name"], (tc["arguments"] if isinstance(tc["arguments"], dict) else {})823            tcid = tc["id"] or f"call_{step}_{i}"824            if name == "terminate":825                # enforceLoop 计数模式:enforce_tool 没调够 enforce_min 次 → 禁止结束。826                got = tool_counts.get(enforce_tool, 0)827                if enforce_tool and enforce_min > 0 and got < enforce_min:828                    msg = (f"[SYSTEM] 还不能结束:{enforce_tool} 已调用 {got}/{enforce_min} 次,"829                           f"还需 {enforce_min - got} 次。请继续完成后再 terminate。")830                    messages.append({"role": "tool", "tool_call_id": tcid, "content": msg})831                    _emit(STEP_TOOL_RESULT, step, msg, tool="terminate")832                    continue  # 不 terminate,循环继续833                final = (args.get("summary") or "").strip() or content or final834                terminated = True835                messages.append({"role": "tool", "tool_call_id": tcid, "content": "ok"})836                continue837            _emit(STEP_TOOL_CALL, step, json.dumps(args, ensure_ascii=False), tool=name)838            _t0 = _time.time()839            tool = tools.get(name)840            if tool is None:841                obs = f"[TOOL_ERROR] unknown tool: {name}"842            else:843                try:844                    obs = str(_timeout_call(tool, args, tool_timeout))845                except Exception as e:846                    obs = f"[TOOL_ERROR] {e}"847            tool_counts[name] = tool_counts.get(name, 0) + 1848            _emit(STEP_TOOL_RESULT, step, obs, tool=name)849            if ctx is not None:  # per-tool 写 trace → 前端步数/trace 面板可见 FC 过程850                try:851                    ctx.log(f"fc:{name}", "", args, obs[:500], (_time.time() - _t0) * 1000)852                except Exception:853                    pass854            messages.append({"role": "tool", "tool_call_id": tcid,855                             "content": obs[:_MAX_OBS_LENGTH]})856        if terminated:857            break858    return final859860861class _FCReactTerm(Term):862    """FC react 车道的 Term(apply 收 ctx → 能写 trace)。比 Tool 多带 ctx 与 enforceLoop。"""863    def __init__(self, name, *, provider, system_prompt, tools, tools_schema,864                 max_steps, tool_timeout, on_step, cancel, enforce_tool, enforce_min):865        super().__init__(name)866        self._p = provider; self._sys = system_prompt; self._tools = tools867        self._schema = tools_schema; self._ms = max_steps; self._tt = tool_timeout868        self._os = on_step; self._c = cancel869        self._etool = enforce_tool; self._emin = enforce_min870871    def apply(self, input, ctx=None):872        return _run_fc_loop(self._p, self._sys, str(input), self._tools, self._schema,873                            max_steps=self._ms, tool_timeout=self._tt, on_step=self._os,874                            cancel=self._c, ctx=ctx, enforce_tool=self._etool,875                            enforce_min=self._emin)876877878def _compile_react(cfg: Dict, overrides: Dict) -> Term:

FC 版 ReAct 循环:chat_with_tools(messages, tools) → 直接执行返回的 tool_calls → 以 role=tool 回灌。复用 _timeout_call 与 StepEvent,带 enforceLoop 计数。
`lambdagent/src/lambdagent/fromconfig/compiler.py` · _tools_json_schema · L736-778

    736def _tools_json_schema(cfg: Dict) -> list:737    """从工具 schema 类的 __init__ 注解生成 OpenAI function-calling tools 规格,738    供原生 tool_calls 用(替代 _generate_tool_schema_docs 的文本文档)。739    见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md 阶段 1。"""740    import inspect741    try:742        from lambdagent.builtin_tools.registry import BUILTIN_TOOLS743    except ImportError:744        return []745    names = (cfg.get("mcp", {}) or {}).get("localTools", []) or []746    out = []747    for name in names:748        if name == "terminate":749            out.append({"type": "function", "function": {750                "name": "terminate", "description": "结束任务并返回结果摘要",751                "parameters": {"type": "object",752                               "properties": {"summary": {"type": "string",753                                              "description": "结果摘要"}},754                               "required": []}}})755            continue756        tool = BUILTIN_TOOLS.get(name)757        schema_cls = getattr(tool, "schema", None) if tool else None758        if not schema_cls:759            continue760        try:761            sig = inspect.signature(schema_cls.__init__)762        except (TypeError, ValueError):763            continue764        props, required = {}, []765        for pn, p in sig.parameters.items():766            if pn == "self":767                continue768            props[pn] = {"type": _json_type_of(p.annotation)}769            if p.default is inspect.Parameter.empty:770                required.append(pn)771        out.append({"type": "function", "function": {772            "name": name,773            "description": getattr(tool, "description", "") or name,774            "parameters": {"type": "object", "properties": props, "required": required}}})775    return out776777778def _run_fc_loop(provider, system_prompt: str, user_input: str, tools: Dict,

从工具 schema 类的 __init__ 注解生成 OpenAI tools 规格。坑:from __future__ import annotations 让注解变字符串,需 _json_type_of 按名兜底(int→integer)。

### ∑ 形式化对象

Loop(chat_with_tools ▷ exec_tool_calls, cond, max_steps)
effect: IO[tool]

相比范式①,这里**没有 Partial**:tool_calls 由 API 按 schema 产出并校验, 参数类型在调用边界即被约束,解析失败这一失效模式在类型层被消除。 这是"形式化提前拒绝"的具体兑现:坏调用编译/校验期即挡下,而非运行期才暴露。

### 🎞 Live Demo

对照实验在「双车道对比台」(机制③,evals/bench_providers.py)录制:同一 golden 任务并排跑范式①②③,出"执行次数 / 落盘次数 / 格式飘移率 / token / cost"对照表。 已知 live 结果:qwen-plus 走 FC 真返回结构化 tool_call,file_path 不飘。

学习目标 / Takeaway 与范式①并排看,核心差异是一个 effect 标注:Partial 的有无。 学生应能指出:范式①的失效来自"模型写错文本",范式②把这条路堵死在 API 协议层; 但要强调 codex 的修正——FC 只治格式/解析类问题,"路径落错(写到别处)"是工具层 (_resolve / _sandbox)的事,见范式相关的沙箱卡(week 8)。

相关对照卡: 工具调用范式①:react-over-text(把工具调用当散文手写 JSON) 工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)