[{"data":1,"prerenderedAt":46},["ShallowReactive",2],{"article-agent\u002F04-react-from-scratch":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"tags":11,"body":14,"_type":40,"_id":41,"_source":42,"_file":43,"_stem":44,"_extension":45},"\u002Farticles\u002Fagent\u002F04-react-from-scratch","agent",false,"","04-从零手写 ReAct 循环：Agent 的心跳是怎么转起来的","不依赖任何框架，用原生 tool_calls + 一个 while 循环手动跑通 ReAct。搞懂四件框架替你藏起来的事：messages 为何越滚越长、tool_call.id 配对、finish_reason 含义、max_iter 安全网。","2026-06-15",[12,13],"Agent开发","人工智能",{"type":15,"children":16,"toc":35},"root",[17,25],{"type":18,"tag":19,"props":20,"children":22},"element","h3",{"id":21},"直接看代码注释",[23],{"type":24,"value":21},"text",{"type":18,"tag":26,"props":27,"children":29},"pre",{"code":28},"\"\"\"练习 3：从零手写 ReAct 循环（对齐官方仓库写法）—— Agent 的“心跳”\n\n对应 awesome-agentic-ai-zh \u002F examples\u002Fstage-3\u002F03-react-from-scratch。\n\n这里的“从零”= 不用 LangGraph \u002F CrewAI 这类框架，自己用一个 while 把\n    Thought → Action → Observation → Thought → …\n转起来；但【工具调用仍走 API 原生的 tool_calls】，而不是自己解析文本\n（自己解析文本的经典写法见对照文件 04-react-text-protocol.py）。\n自己写一遍这个循环，才会真正搞懂框架替你藏起来的 4 件事：\n    1) messages 数组为什么会越滚越长（每轮都把 assistant + tool 结果追加进去）；\n    2) tool_call.id 和工具结果（role=\"tool\" 的 tool_call_id）怎么配对；\n    3) finish_reason 为什么是 \"tool_calls\"（还要继续）或 \"stop\"（可以收尾）；\n    4) max_iter 为什么是必须的 safety net（防止无限循环烧钱）。\n\n模型用 DeepSeek 云服务（OpenAI 兼容），配置见根目录 .env。\n\"\"\"\n\nimport json\nimport os\n\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\n\nclient = OpenAI(\n    api_key=os.environ[\"DEEPSEEK_API_KEY\"],\n    base_url=os.environ[\"DEEPSEEK_BASE_URL\"],\n)\nMODEL = os.environ[\"MODEL\"]\n\n\n# === 1. 工具定义（含实作）—— 我们的“手”，真正执行的代码 ===\ndef lookup_fact(query: str) -> str:\n    \"\"\"假的事实查询（教学用，避免依赖外部 API）。\"\"\"\n    facts = {\n        \"台北人口\": \"2602000\",\n        \"纽约人口\": \"8336000\",\n        \"光速\": \"299792458\",  # m\u002Fs\n    }\n    return facts.get(query.strip(), f\"unknown: {query}\")\n\n\ndef calculator(expression: str) -> str:\n    \"\"\"安全计算器：只允许数字和基本运算符，避免 eval 执行危险代码。\"\"\"\n    allowed = set(\"0123456789.+-*\u002F() \")\n    if any(c not in allowed for c in expression):\n        return f\"error: 表达式含不允许字符（{expression}）\"\n    try:\n        return str(eval(expression, {\"__builtins__\": {}}, {}))  # 已用白名单兜底\n    except Exception as e:\n        return f\"error: {e}\"\n\n\n# OpenAI 兼容的 tools schema：包一层 {\"type\":\"function\",\"function\":{...}}\n# 注意 description 写清“什么时候用”——这是模型在多工具间选对的依据。\nTOOLS_SPEC = [\n    {\n        \"type\": \"function\",\n        \"function\": {\n            \"name\": \"lookup_fact\",\n            \"description\": \"查询一个事实（人口 \u002F 物理常数等）。需要外部事实时调用。\",\n            \"parameters\": {\n                \"type\": \"object\",\n                \"properties\": {\"query\": {\"type\": \"string\", \"description\": \"查询关键字\"}},\n                \"required\": [\"query\"],\n            },\n        },\n    },\n    {\n        \"type\": \"function\",\n        \"function\": {\n            \"name\": \"calculator\",\n            \"description\": \"做基本算术运算（加减乘除）。需要计算时调用。\",\n            \"parameters\": {\n                \"type\": \"object\",\n                \"properties\": {\"expression\": {\"type\": \"string\", \"description\": \"算术表达式\"}},\n                \"required\": [\"expression\"],\n            },\n        },\n    },\n]\n\n# 工具名 -> 真实函数 的调度表，模型选了谁就执行谁\nTOOL_IMPL = {\n    \"lookup_fact\": lambda args: lookup_fact(args[\"query\"]),\n    \"calculator\": lambda args: calculator(args[\"expression\"]),\n}\n\n\n# === 2. ReAct 主循环 —— agent 的心跳 ===\ndef react_loop(question: str, max_iter: int = 6) -> dict:\n    \"\"\"每一轮：问 LLM → 若它要调工具就执行并把结果接回 → 直到它给出最终答案。\"\"\"\n    messages = [{\"role\": \"user\", \"content\": question}]\n    trace = []  # 记录每步轨迹，方便观察循环怎么转\n\n    # max_iter 是熔断护栏：万一模型卡在循环里，也不会无限调用下去（机制④）\n    for step in range(max_iter):\n        # ① 想 + 决定：让模型在看完目前 messages + 工具后，决定下一步\n        resp = client.chat.completions.create(\n            model=MODEL,\n            messages=messages,\n            tools=TOOLS_SPEC,\n        )\n        choice = resp.choices[0]\n        msg = choice.message\n        tool_calls = msg.tool_calls or []\n\n        # 把这一轮 assistant 的发言追加回 messages —— 这就是数组越滚越长的原因（机制①）\n        assistant_entry = {\"role\": \"assistant\", \"content\": msg.content or \"\"}\n        if tool_calls:\n            assistant_entry[\"tool_calls\"] = [\n                {\n                    \"id\": tc.id,\n                    \"type\": \"function\",\n                    \"function\": {\"name\": tc.function.name, \"arguments\": tc.function.arguments},\n                }\n                for tc in tool_calls\n            ]\n        messages.append(assistant_entry)\n\n        # ② 终止条件：finish_reason 为 \"stop\"（或没有 tool_calls）说明模型要收尾了（机制③）\n        if choice.finish_reason == \"stop\" or not tool_calls:\n            trace.append({\"step\": step, \"thought\": msg.content, \"tool\": None})\n            return {\"final\": msg.content, \"trace\": trace, \"steps\": step + 1}\n\n        # ③ 做 + 看：执行模型选中的每个工具，把结果以 role=\"tool\" 接回\n        for tc in tool_calls:\n            args = json.loads(tc.function.arguments)\n            fn = TOOL_IMPL.get(tc.function.name)\n            obs = fn(args) if fn else f\"error: unknown tool {tc.function.name}\"\n            print(f\"[step {step}] {tc.function.name}({args}) → {obs}\")\n\n            # 关键：tool 结果必须带 tool_call_id，和上面那次调用的 id 配对（机制②）\n            messages.append(\n                {\n                    \"role\": \"tool\",\n                    \"tool_call_id\": tc.id,\n                    \"content\": obs,\n                }\n            )\n            trace.append(\n                {\"step\": step, \"thought\": msg.content, \"tool\": tc.function.name, \"obs\": obs}\n            )\n        # 循环回到 ①，模型带着新 Observation 继续“想”——心跳就这样一下下转起来\n\n    # 跑满 max_iter 仍没收尾：显式标记 truncated，绝不假装成功\n    return {\"final\": None, \"trace\": trace, \"steps\": max_iter, \"truncated\": True}\n\n\n# === 3. 自我验证 ===\nif __name__ == \"__main__\":\n    # 这个任务需要循环转好几圈：查台北人口 → 查纽约人口 → 相除 → 给答案\n    question = \"台北人口除以纽约人口是多少？保留 4 位小数。\"\n    print(f\"❓ 问题：{question}（using DeepSeek {MODEL}）\")\n    print(\"-\" * 60)\n\n    result = react_loop(question, max_iter=6)\n\n    print(\"-\" * 60)\n    print(f\"✅ 最终答案：{result['final']}\")\n    print(f\"   共 {result['steps']} 轮\")\n\n    # 宽松验证：循环应当正常收尾（给出答案），或显式 truncate，绝不静默失败\n    assert result.get(\"final\") is not None or result.get(\"truncated\"), \"loop 应收尾或显式 truncate\"\n    print(\"✅ 练习 3 通过 —— 你已用原生 tool_calls + 手动 while 循环跑通 ReAct\")\n",[30],{"type":18,"tag":31,"props":32,"children":33},"code",{"__ignoreMap":7},[34],{"type":24,"value":28},{"title":7,"searchDepth":36,"depth":36,"links":37},2,[38],{"id":21,"depth":39,"text":21},3,"markdown","content:articles:agent:04-react-from-scratch.md","content","articles\u002Fagent\u002F04-react-from-scratch.md","articles\u002Fagent\u002F04-react-from-scratch","md",1781678620532]