现代 LLM API(OpenAI / Anthropic / 兼容协议)原生支持 function calling: 把可用工具以 JSON Schema 形式声明给模型,模型在需要时返回一个**结构化**的 tool_calls 对象(工具名 + 已按 schema 校验的参数),而不是一段自由文本。 平台执行完工具后,以 role=tool 的消息把结果回灌,继续下一轮。整个过程里 "调用"是 API 协议的一等公民,参数有结构、可校验,不存在"解析模型散文"这一步。
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: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,相比范式①,这里**没有 Partial**:tool_calls 由 API 按 schema 产出并校验, 参数类型在调用边界即被约束,解析失败这一失效模式在类型层被消除。 这是"形式化提前拒绝"的具体兑现:坏调用编译/校验期即挡下,而非运行期才暴露。
对照实验在「双车道对比台」(机制③,evals/bench_providers.py)录制:同一 golden 任务并排跑范式①②③,出"执行次数 / 落盘次数 / 格式飘移率 / token / cost"对照表。 已知 live 结果:qwen-plus 走 FC 真返回结构化 tool_call,file_path 不飘。
相关对照卡: 工具调用范式①:react-over-text(把工具调用当散文手写 JSON) 工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)