# 工具调用范式①:react-over-text(把工具调用当散文手写 JSON)

← 对照卡索引
🎯 react-over-text 把工具调用降格成"生成文本 → 正则解析 JSON",是 0 执行 / 格式飘移 / path-vs-file_path 不一致的总根源。它适合做 fallback,不该当主车道。

### 📖 教科书概念

ReAct(Yao et al., 2022)让 LLM 在 Thought → Action → Observation 之间交替: 模型先"想"(Thought),再发一个"动作"(Action),环境执行后把结果(Observation) 回灌,如此循环直到给出答案。 最朴素的工程实现是 **react-over-text**:不依赖模型原生的工具调用能力,而是要求 模型把 Action 以一段约定格式的文本写出来(通常是一段 JSON),平台再用正则把这段 文本"抠"出来、自己去执行。模型在这里并不"调用"工具,它只是在"描述"一个调用。

### 💻 平台源码落点

`lambdagent/src/lambdagent/fromconfig/compiler.py` · _compile_react · L878-933

    878def _compile_react(cfg: Dict, overrides: Dict) -> Term:879    """880    type: react -> Loop(react_step, condition, max_steps)881882    v2 optimizations:883      - Context passed through via closure over shared ctx884      - Sliding window state compression885      - Early termination on implicit signals886      - Tool call caching887    """888    # Inject tool parameter docs into system prompt889    cfg = dict(cfg)  # shallow copy to avoid mutating original890    tool_docs = _generate_tool_schema_docs(cfg)891    if tool_docs:892        cfg["systemPrompt"] = cfg.get("systemPrompt", "") + tool_docs893894    # 可选:磁盘进度纪律(react.progressDiscipline)。把 PROGRESS.md 待办纪律注入 systemPrompt,895    # 防多步任务"上下文截断→失忆→工具调用乱跳/重做"。默认 off,只对显式开启的 react agent 注入;896    # simple 子智能体走 _compile_lam 不走这里,天然不受影响。见 docs/PROGRESS_DISCIPLINE_DESIGN.md。897    if (cfg.get("react", {}) or {}).get("progressDiscipline"):898        cfg["systemPrompt"] = cfg.get("systemPrompt", "") + _PROGRESS_DISCIPLINE_FRAGMENT899900    think = _compile_lam(cfg, "think", overrides=overrides)901    tools = _compile_tools(cfg, overrides)902903    # S05: Enforce mcp.policy.mode at runtime904    mcp_cfg = cfg.get("mcp", {})905    mcp_policy_mode = mcp_cfg.get("policy", {}).get("mode", "auto")906    if mcp_policy_mode == "disable":907        tools = {"terminate": tools.get("terminate", Tool("terminate", fn=lambda x: x))}908    elif mcp_policy_mode == "force":909        pass  # All tools available, forced execution910    # "auto" and "intelligence" are default behavior (LLM decides)911912    react_cfg = cfg.get("react", {})913    max_steps = react_cfg.get("maxSteps", 10)914    tool_timeout = react_cfg.get("toolTimeout", 30)915    observation_enabled = react_cfg.get("observationEnabled", True)916    verbose = react_cfg.get("verbose", False)917    agent_name = cfg.get("name", cfg.get("agentId", "agent"))918919    # ── 原生 function-calling 车道(react.nativeToolCalls,阶段 2)──920    # FC-capable provider(OpenAI 兼容:qwen/openai/deepseek…)+ 开关 → 用结构化921    # tool_calls 直接执行,根治"文本求 JSON、正则抠"那一类 0 执行/格式飘 bug。922    # 旧文本路径(下方)保留作 fallback:provider 不支持 FC 或开关 off 时走它。923    # 见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md。924    if react_cfg.get("nativeToolCalls"):925        _prov = getattr(think, "provider", None)926        if (_prov is not None and hasattr(_prov, "chat_with_tools")927                and getattr(_prov, "supports_function_calling", lambda: False)()):928            try:929                from lambdagent.agentruntime import cancel as _fc_cancel930            except Exception:931                _fc_cancel = None932            # enforceLoop 计数模式(单体 agent 用):terminate 前 tool 须调够 minCount 次。933            # 序列模式(orchestrator)FC 暂不支持——那些 agent 不设 nativeToolCalls。

文本车道主入口。顶部做门控分叉:react.nativeToolCalls 关闭时(默认), 返回基于 ConversationLam 的文本 ReAct 循环,模型输出靠正则解析。
`lambdagent/src/lambdagent/conversation.py` · ConversationLam · L60-126

    60class ConversationLam(Term):61    """62    Lambda abstraction with conversation persistence.6364    Lambda semantics preserved:65        ConversationLam(provider, prompt) = lambda x. provider(history + x)66        apply() = beta-reduction with memory6768    The key difference from stateless Lam:69        Lam:             each apply() is independent70        ConversationLam: each apply() builds on all previous calls7172    L03: Now supports both legacy dict-based and ChatMessage-based flows.73    """7475    def __init__(76        self,77        name: str,78        provider: LLMProvider,79        system_prompt: str,80        max_history_tokens: int = 80000,81        keep_recent_turns: int = 20,82        output_parser: Callable[[str], Any] | None = None,83        # L03: New unified interface params84        model: str = "",85        temperature: float = 0.0,86        max_tokens: int = 4096,87        # Hard character ceiling for the *rendered* request (system + history +88        # new input). Providers like DashScope/qwen reject inputs over a fixed89        # CHARACTER count (qwen: 258048 → "Range of input length should be90        # [1, 258048]"). The token-based budget above is not enough on its own:91        # for CJK text 1 token ≈ 1 char, so an 80k-"token" history estimated as92        # 320k chars sails past the char limit. We keep a conservative margin93        # below 258048 for the system prompt + the next user turn.94        max_input_chars: int = 200000,95    ):96        super().__init__(name)97        self.provider = provider98        self.system_prompt = system_prompt99        self.max_history_tokens = max_history_tokens100        self.keep_recent_turns = keep_recent_turns101        self.max_input_chars = max_input_chars102        self.output_parser = output_parser or (lambda x: x)103        # L03: Store model/temperature/max_tokens for chat_typed calls104        self._model = model105        self._temperature = temperature106        self._max_tokens = max_tokens107108        # Conversation history (system message always first)109        self.messages: List[dict] = [110            {"role": "system", "content": system_prompt}111        ]112113        # L03: ChatMessage-based history (parallel to dict-based for typed path)114        self._typed_messages: List[ChatMessage] = []115116    # Expose model name for react_step logging117    @property118    def model(self) -> str:119        return self._model or self.provider.model_name120121    # Expose _session_id for react_step session detection122    @property123    def _session_id(self):124        return getattr(self.provider, '_session_id', None)125126    def apply(self, input: Any, ctx: Context | None = None) -> Any:

文本车道的"思考"算子。每步把历史 + 最新 observation 拼成 prompt 发给 provider, 拿回纯文本,再交给上层正则解析出 action。靠 max_input_chars 兜住上下文膨胀。

### ∑ 形式化对象

Loop(ConversationLam ∘ action_parser, cond, max_steps)
effect: IO[tool] ⊕ Partial

action_parser 是一个**部分函数**:模型文本不合约定时解析失败(返回空动作或抛错)。 这个"部分性(Partial)"正是范式②原生 FC 要从类型上消除的东西——FC 让工具调用 成为结构化、类型受控的对象,而非一段可能解析失败的散文。

### 🎞 Live Demo · 真实 run

`agentexample/research67/workspace/run_20260520_004928` · status=completed
999 步
0 tokens
18535.7 s
ReadFile ×998WriteFile ×998

999 步 react_step,工具调用全部以文本形式嵌在每步 output 里(看 "[Step N] X done")。 这是 react-over-text 在长任务里空转/重做的典型形态:步数巨大,真正落盘寥寥。

学习目标 / Takeaway 对照三栏后学生应能说清: 1. 概念上,react-over-text 仍是合法的 ReAct,只是 Action 用文本承载; 2. 代码上,它落在 _compile_react 的"非 nativeToolCalls"分支 + ConversationLam; 3. 形式上,它引入了一个部分函数 action_parser,把"解析失败"这一失效模式带进了系统。 这就是"为什么需要形式化"的第一个实证:范式②用类型把这个失效模式提前消除。

相关对照卡: 工具调用范式②:原生 function-calling(结构化 tool_calls) 工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)