← 对照卡索引
模块 A第 2 周 tool-native-fc

工具调用范式②:原生 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 = None
791
792 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 pass
798
799 import time as _time
800 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 final
814 break
815 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 = False
821 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 final
834 terminated = True
835 messages.append({"role": "tool", "tool_call_id": tcid, "content": "ok"})
836 continue
837 _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) + 1
848 _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 pass
854 messages.append({"role": "tool", "tool_call_id": tcid,
855 "content": obs[:_MAX_OBS_LENGTH]})
856 if terminated:
857 break
858 return final
859
860
861class _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 = tools
867 self._schema = tools_schema; self._ms = max_steps; self._tt = tool_timeout
868 self._os = on_step; self._c = cancel
869 self._etool = enforce_tool; self._emin = enforce_min
870
871 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)
876
877
878def _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 inspect
741 try:
742 from lambdagent.builtin_tools.registry import BUILTIN_TOOLS
743 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 continue
756 tool = BUILTIN_TOOLS.get(name)
757 schema_cls = getattr(tool, "schema", None) if tool else None
758 if not schema_cls:
759 continue
760 try:
761 sig = inspect.signature(schema_cls.__init__)
762 except (TypeError, ValueError):
763 continue
764 props, required = {}, []
765 for pn, p in sig.parameters.items():
766 if pn == "self":
767 continue
768 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 out
776
777
778def _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 原生车道(把工具调用外包给运行时)