Function Calling

W2 · Function Calling 演进记:从最小闭环到模型路由

关键词:tool_calls / fc_loop / 工具分发 / MockLLM / 模型路由 / tool_records 前置:W1(llm_client、Prompt 四模式) 本篇内容:FC 闭环五版演进,从"能跑"到"能测"到"能防幻觉" 收尾预告:W3 MemoryManager——让这辆能调工具的车,拥有记忆


零、先说清楚:Function Calling 到底在解决什么

0.1 一个扎心的事实

大模型不会执行工具。 你让它查天气,它只会编一个天气给你——因为它压根没法联网。

但模型有一个能力:它能读懂"有哪些工具可用、各要什么参数",然后输出一句"我要调 get_weather,参数是 {"city": "北京"}"——注意,它只是说要调,不会真的调。

1
2
3
4
5
6
  模型的真实输出(不是答案,是"意图"):
  {
    "name": "get_weather",
    "arguments": "{\"city\": \"北京\"}"
  }
  ← 这是一张"点菜单",不是"菜"

Function Calling(FC)= 你在模型和真实世界之间,当那个"接单的人"。

0.2 闭环四步(全文的总纲)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  ① 函数注册  告诉模型:"我有这些工具、各要什么参数"(tools 列表)
        │
        ▼
  ② 模型决定  输出"我要调 get_weather,参数 city=北京"(不是直接回答)
        │
        ▼
  ③ 代码执行  解析函数名和参数,真正调用 get_weather("北京")
        │
        ▼
  ④ 结果回填  把返回值作为 tool 消息喂回模型 → 模型基于真实结果回答
        │              ↑ 如果还需要别的信息,重复 ②③④
        └──────────────┘ 直到模型直接给最终答案

类比:模型是客人,只会看菜单点菜,不会进厨房。FC 闭环里:

  • ① 菜单(tools 注册)
  • ② 客人下单(tool_calls)
  • ③ 厨房做菜(你的代码执行工具)
  • ④ 上菜给客人看(结果回填),客人尝过再决定要不要加点别的

本文就干一件事:把这个闭环从零手写出来,并让它一步步变皮实。 五版进化,每一版解决上一版的一个痛点。


一、版本 1:最小可跑闭环(先让循环转起来)

1.1 FC 循环的"骨架清单"

写任何 FC 循环前,先在心里过这五条:

1
2
3
4
5
6
  ① 开头:messages = [{"role": "user", ...}]     ← 只有一条用户消息
  ② 循环:for _ in range(MAX_ROUNDS):            ← 最多转几圈(防死循环)
  ③ 判断:if not msg.tool_calls: return           ← 模型没要工具 → 结束
  ④ 有工具:append assistant(带tool_calls)
           + 执行工具 + append tool 消息
  ⑤ 结果:模型看到 tool 结果再回答 → 回到 ③

这五条就是 fc_loop 的全部。 后面所有版本都是在这五条上做加固,骨架从没变过。

1.2 最小代码(逐行注释)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import json
import os
from openai import OpenAI


# ── 工具本体:真正干活的函数 ──
def get_weather(city: str) -> str:
    """天气实际查询函数(版本1先用假数据,把链路跑通再说)"""
    return f"{city}今天台风,注意安全"


# ── ① 函数注册:手写 tools schema,告诉模型有哪些工具 ──
# 这段 JSON 就是"菜单":模型靠它知道 get_weather 要一个 city 参数
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "获取指定城市天气",          # 模型靠这句决定何时调用
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"}
                },
                "required": ["city"],
            },
        },
    }
]

messages = [{"role": "user", "content": "今天北京天气怎么样"}]

client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",       # DeepSeek 走 OpenAI 兼容协议
)

# ── ② 模型决定:带 tools 参数发起请求 ──
res = client.chat.completions.create(
    model="deepseek-v4-flash",
    messages=messages,
    tools=tools,          # ← 关键:不传 tools,模型永远不会"点菜"
    max_tokens=100,
)
msg = res.choices[0].message

# ── ③ 判断 + 执行 + ④ 回填 ──
if msg.tool_calls:
    # 把模型这条"带点菜意图"的消息原样追加进历史
    messages.append(msg)
    for tc in msg.tool_calls:
        # tc.function.arguments 是 JSON 字符串,要解析成 dict 再展开传参
        result = get_weather(**json.loads(tc.function.arguments))
        messages.append({
            "role": "tool",
            "tool_call_id": tc.id,     # ← 必须和模型给的 id 一一对应
            "content": result,
        })
    # 带着工具结果再问一次模型,这次它该给最终答案了
    final = client.chat.completions.create(
        model="deepseek-v4-flash", messages=messages, tools=tools
    )
    print(final.choices[0].message.content)

1.3 两个必须记牢的协议细节

① tool_call_id 必须一一对应。

模型发了几个 tool_calls,你就必须回填几条 tool 消息,每条带着模型的 id。少一条或 id 对不上,下一轮请求就报 400——这是协议,不是建议。

② 模型的那条 tool_calls 消息必须原样 append 进 messages。

1
messages.append(msg)   # msg 是带 tool_calls 的 assistant 消息,不能丢

如果不把它加回去,模型下一轮就"失忆"了——它不知道自己刚才点过菜。

⚠️ 这个最小版有个明显的短板:只能处理一轮工具调用。 如果模型第一次要查两个城市、或者查完还要算数,这个 if 结构就转不动了。版本 2 解决它。


二、版本 2:真实 API + 循环 + 工具分发(让循环自己转)

2.1 三个升级

1
2
3
4
5
6
  版本 1                    版本 2
  ───────                  ───────
  if 结构,单轮            for 循环,最多 max_runs 轮
  get_weather 假数据        wttr.in 真实天气 API
  执行写死在 if 里           run_status 统一分发(名字 → 函数)
  (无出错保护)             出错不崩,把错误喂回给模型自纠

版本 2 引进了 FC 的第二个核心抽象:工具分发器 run_status。

2.2 完整代码(逐行注释)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import json
import os
import urllib.parse
import urllib.request
from openai import OpenAI


# ── 工具本体:这次是真 API(wttr.in,免 key)──
def get_weather(city: str) -> str:
    city_encoded = urllib.parse.quote(city)     # 中文 → URL 编码:深圳 → %E6%B7%B1%E5%9C%B3
    url = f"https://wttr.in/{city_encoded}?format=3"
    return urllib.request.urlopen(url).read().decode()   # 返回简短天气文本


# ── ① 函数注册(schema 和版本1一样)──
TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "获取指定城市天气",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    }
]

# ── 工具查表:名字 → 函数(模型只给名字,代码负责翻译)──
TOOLS_FUNC = {"get_weather": get_weather}


def run_status(name: str, args: dict) -> str:
    """工具分发器:模型说"调 get_weather",这里负责真的调。
    原则:出错不崩,把错误信息返回给模型,让它自己修正。"""
    try:
        if name not in TOOLS_FUNC:
            return f"未知工具:{name}"          # 模型也可能幻觉出不存在的工具
        return TOOLS_FUNC[name](**args)        # 按名字查到函数,展开参数调用
    except Exception as e:
        return f"工具执行失败:{e}"             # 失败也要"上菜",上的是错误信息


def fc_loop(user_input: str, max_runs: int = 3) -> str:
    """FC 主循环:②③④ 反复转,直到模型直接给答案,或转满上限。"""
    messages = [{"role": "user", "content": user_input}]
    client = OpenAI(
        api_key=os.getenv("DEEPSEEK_API_KEY"),
        base_url="https://api.deepseek.com",
    )

    for _ in range(max_runs):                  # ② 最多转几圈
        res = client.chat.completions.create(
            model="deepseek-v4-flash",
            messages=messages,
            tools=TOOLS_SCHEMA,
            max_tokens=100,
        )
        msg = res.choices[0].message

        if not msg.tool_calls:                 # ③ 模型没要工具 → 给最终答案
            return msg.content

        messages.append(msg)                   # ④ 把带 tool_calls 的消息存进历史
        for tc in msg.tool_calls:
            try:
                arg = json.loads(tc.function.arguments)   # arguments 是 JSON 字符串
            except Exception as e:
                # 参数解析失败也回填——让模型看到"你给的东西我解析不了"
                messages.append({"role": "tool", "tool_call_id": tc.id,
                                 "content": f"参数解析失败:{e}"})
                continue
            result = run_status(tc.function.name, arg)    # 统一分发执行
            messages.append({"role": "tool", "tool_call_id": tc.id,
                             "content": result})          # 结果回填(在 for 内!)

    return "达到最大轮数,已停止"               # 转满上限,兜底返回


if __name__ == "__main__":
    print(fc_loop(input("天气助手:")))

2.3 🎯 版本 2 最值钱的设计:出错也回填

1
2
except Exception as e:
    return f"工具执行失败:{e}"

为什么工具出错也要把错误喂回给模型,而不是自己 raise?

1
2
3
4
5
  ❌ 自己抛异常:程序崩了,用户只看到 traceback
  ✅ 错误回填:  模型看到"工具执行失败:xxx",会自己换个说法重试、
                 换个参数再调、或直接道歉给答案

  本质:把"调试期你自己盯着"的事,变成"模型现场自愈"

类比:服务员下单后厨房说"这道菜做不了"。你不能假装没下过单——正确做法是把"这道菜没了"也端上桌,让客人(模型)自己决定换菜还是结账。工具挂了,对话不能挂。

2.4 ⚠️ 顺带记住一个坑:多工具回填的缩进

版本 1 那种 if 写法一旦改成 for 循环,回填 messages.append 必须在 for tc 循环体内部:

1
2
3
4
for tc in msg.tool_calls:
    result = run_status(tc.function.name, arg)
    messages.append({...})    # ✅ 在这里:每个 tc 都回填一条
# messages.append({...})      # ❌ 在这里:只有最后一条被回填,其余 400

模型一次发了 3 个 tool_calls,你只回填 1 条,协议就不完整。这段代码你以后在 LangGraph 里会再遇到一次——ToolNode 替你把这个 for 循环写了。

💡 你现在手写踩的每个坑(回填不齐、id 对不上、解析失败),都是将来理解 ToolNode“自动替你做了什么"的素材。先手写,再框架,你才知道框架替你省了什么。


三、版本 3:MockLLM——不烧 token 的假模型

3.1 为什么要造一个假模型

版本 2 能跑了,但每测一次就烧一次 API 钱。而且真模型是"概率输出”——这次返回 2 个 tool_calls,下次可能直接回答,你没法稳定复现要测的分支。

类比:你要测试"客人点菜 → 厨房做菜 → 上菜"这条流水线,不该每次都请真客人来——请个假人按剧本点菜,才能稳定验证流水线本身有没有 bug。

版本 3 的思路:仿照 OpenAI 的返回结构,写一个假模型 MockLLM。 它不联网、不烧 token,根据输入关键词按剧本返回 tool_calls 或最终回答。

3.2 抽象基类:先把"模型"的接口钉死

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class LLMClient:
    """模型客户端抽象:真模型、假模型都实现 chat(),调用方不关心是谁"""
    def chat(self, message, tools=None):
        raise NotImplementedError("子类必须实现 chat()")


class RealLLM(LLMClient):
    """真模型:对接 DeepSeek(OpenAI 兼容协议)"""
    def __init__(self, provide="deepseek"):
        cfg = {
            "deepseek": {"base_url": "https://api.deepseek.com",
                         "key": "DEEPSEEK_API_KEY", "model": "deepseek-v4-flash"},
            "openai":   {"base_url": None,
                         "key": "OPENAI_API_KEY", "model": "gpt-4o-mini"},
        }[provide]
        self.client = OpenAI(api_key=os.getenv(cfg["key"]), base_url=cfg["base_url"])
        self.model = cfg["model"]

    def chat(self, messages, tools=None):
        return self.client.chat.completions.create(
            model=self.model, messages=messages, tools=tools
        )

为什么值得抽这个基类:fc_loop 只认 llm.chat(...),不管背后是真模型还是假模型。接口钉死了,测试才能替换——这就是依赖注入的最小形态。

3.3 仿造 OpenAI 的返回结构(Fake 系列)

fc_loop 里访问的是 rep.choices[0].message.tool_calls。要造假模型,就得把这条访问链上的每一层都造出来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# ── 仿照 openai SDK 的返回对象,逐层构造 ──
class FakeMessage:
    """仿 message:有 content 和 tool_calls 两个属性"""
    def __init__(self, content=None, tool_calls=None):
        self.content, self.tool_calls = content, tool_calls

class FakeChoice:
    """仿 choices[0]"""
    def __init__(self, message):
        self.message = message

class FakeResponse:
    """仿 response:.choices 是个列表,装着 message"""
    def __init__(self, message):
        self.choices = [FakeChoice(message)]

class FakeFunction:
    """仿 tool_calls[i].function:.name 和 .arguments"""
    def __init__(self, name, arguments):
        self.name, self.arguments = name, arguments

class FakeToolCall:
    """仿 tool_calls[i]:.id 和 .function"""
    def __init__(self, name, arguments):
        self.id = "call_mock_001"
        self.function = FakeFunction(name, arguments)

💡 为什么要仿到这么细? 因为 fc_loop 读的是 tc.function.arguments、tc.id 这种深层路径。假对象只要把循环要访问的路径都提供出来,fc_loop 一行都不用改就能跑。 这就是"面向协议编程"——循环只认结构,不认实现。

3.4 MockLLM:按剧本演戏的假模型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MockLLM(LLMClient):
    """假模型:不联网、不烧 token,按关键词返回写死的 tool_calls"""
    def chat(self, message, tools=None):
        # 剧本①:最后一条是 tool 结果 → 模型"看到结果",给出最终回答
        if message[-1]["role"] == "tool":
            result = message[-1]["content"]
            return FakeResponse(FakeMessage(
                content=f"模型看到工具结果[{result}]后给出的最终回答"
            ))
        # 剧本②:问题里带"天气" → 返回一个调用 get_weather 的意图
        q = message[-1]["content"]
        if "天气" in q:
            return FakeResponse(FakeMessage(
                tool_calls=[FakeToolCall("get_weather", '{"city":"天津"}')]
            ))
        # 剧本③:问题里带"等于/计算" → 返回一个调用 calculator 的意图
        if "等于" in q or "计算" in q:
            return FakeResponse(FakeMessage(
                tool_calls=[FakeToolCall("calculator", '{"expr": "1+1"}')]
            ))
        # 剧本④:兜底 → 直接给回答
        return FakeResponse(FakeMessage(content="Mock回答"))

看它的"剧本"设计——每个分支对应 fc_loop 的一条路径:

1
2
3
4
5
6
  剧本       触发条件                    验证的路径
  ─────     ──────────                  ──────────────
  ①  tool 消息结尾       → fc_loop 的"结果回填后"分支
  ②  含"天气"           → fc_loop 的"发 tool_calls"分支(get_weather)
  ③  含"等于/计算"      → fc_loop 的"发 tool_calls"分支(calculator)
  ④  其余               → fc_loop 的"直接返回"分支

🎯 这就是 Mock 的核心价值:不是"假装有模型",而是"让模型按剧本走你指定的路径"。 你想测"多轮工具调用",就让剧本 ① 之后返回剧本 ②——循环自动转两圈。真模型做不到这么听话。

3.5 安全版计算器(版本 3 的暗雷)

剧本 ③ 会触发 calculator('{"expr": "1+1"}')。注意:ast.literal_eval 只吃字面量,不吃表达式——literal_eval("1+1") 会抛 ValueError,因为 1+1 是运算不是字面量。

正确做法是白名单式 AST 求值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import ast
import operator as op

# 白名单:只允许这些运算符进"厨房"
_OPS = {
    ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv,
    ast.USub: op.neg, ast.Pow: op.pow,
}

def _safe_eval(node):
    """遍历 AST 求值:只放行数字和 + - * / 幂、取负,其余一律拒绝"""
    if isinstance(node, ast.Expression):
        return _safe_eval(node.body)
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
        return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
    if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
        return _OPS[type(node.op)](_safe_eval(node.operand))
    raise ValueError(f"不支持的表达式节点: {type(node).__name__}")

def calculator(expr: str) -> str:
    """四则运算:不用 eval,白名单遍历 AST——安全且能算 '1+1' 这类表达式"""
    return str(_safe_eval(ast.parse(expr, mode="eval")))
1
2
3
4
5
6
7
  为什么不能直接 eval("1+1")?
    eval 会执行任意代码——用户输入 "1; import os; os.remove(...)" 就完蛋了
    白名单 AST 只认数字和 +-*/,其余节点直接抛错

  literal_eval 呢?
    它只解析"字面量",'1+1' 是表达式不是字面量 → ValueError
    所以要算表达式,得自己遍历 AST 求值

3.6 同一个 fc_loop,换引擎跑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def fc_loop(question: str, llm: LLMClient, max_rounds: int = 3):
    """和版本 2 几乎一样——唯一区别:llm 从外面传进来,真假都行"""
    messages = [{"role": "user", "content": question}]
    for _ in range(max_rounds):
        rep = llm.chat(messages, tools=TOOLS_SCHEMA)
        msg = rep.choices[0].message
        if not msg.tool_calls:
            return msg.content                 # 模型直接回答
        messages.append(msg)                   # 存下带 tool_calls 的消息
        for tc in msg.tool_calls:
            try:
                arg = json.loads(tc.function.arguments)
            except Exception as e:
                messages.append({"role": "tool", "tool_call_id": tc.id,
                                 "content": f"参数解析失败:{e}"})
                continue
            result = run_tool(tc.function.name, arg)
            messages.append({"role": "tool", "tool_call_id": tc.id,
                             "content": result})   # ✅ for 内回填
    return "达到最大访问次数"


def run_tool(name: str, arg: dict) -> str:
    """工具分发(和版本2的 run_status 同款)"""
    try:
        return TOOLS_FUNC[name](**arg)
    except Exception as e:
        return f"参数出错了:{e}"

测试时传 MockLLM(),上线传 RealLLM("deepseek"),循环一个字不用改:

1
2
3
4
5
if __name__ == "__main__":
    # 假模型:零成本验证循环逻辑
    print(fc_loop("天津天气怎么样", MockLLM()))
    # 真模型:验证真实 API 链路
    print(fc_loop("天津天气怎么样", RealLLM("deepseek")))

💡 这版演进教会你的事:把"模型"抽成接口,是 FC 工程化的第一课。 之后你写的任何 Agent 代码,都应该问一句"这里能不能塞个假模型进去测?"——能,说明解耦到位;不能,说明逻辑和实现粘死了。


四、版本 4:真实天气 + 模型路由 + 工具记录(防幻觉)

4.1 三个新东西

1
2
3
  ① get_weather 换 open-meteo:免费、免 key、按经纬度返回结构化天气
  ② route_model:从问题里识别要哪个模型(kimi/deepseek/...)→ 模型硬路由
  ③ fc_loop 返回 {answer, tool_records}:记录每次工具调用 → 检测幻觉

前两个是"更好用",第三个才是这版的重点——它回答了一个尖锐的问题:模型没调工具就说出了答案,你信吗?

4.2 天气工具:open-meteo(按经纬度查)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import json
import urllib.request

# open-meteo 的天气码 → 中文描述(官方文档的码表)
WEATHER_CODE_DESC = {
    0: "晴朗的天空", 1: "主要晴朗", 2: "局部多云", 3: "阴天",
    45: "雾气", 48: "霜雾沉积",
    51: "毛毛雨:轻度", 53: "毛毛雨:中度", 55: "毛毛雨:密集",
    61: "降雨:轻度", 63: "降雨:中度", 65: "降雨:强雨",
    71: "降雪量:轻度", 73: "降雪量:中度", 75: "降雪量:重度",
    80: "阵雨:轻度", 81: "阵雨:中度", 82: "阵雨:猛烈",
    95: "雷暴:轻度或中度", 96: "雷暴伴轻微冰雹", 99: "雷暴伴强烈冰雹",
}

def get_weather(latitude: float, longitude: float) -> str:
    """按经纬度查实时天气(open-meteo,免 key)。返回中文 dict 的 JSON。"""
    url = (f"https://api.open-meteo.com/v1/forecast?latitude={latitude}"
           f"&longitude={longitude}"
           f"&current=temperature_2m,relative_humidity_2m,wind_speed_10m"
           f"&daily=weather_code,temperature_2m_max,temperature_2m_min,"
           f"sunrise,sunset,precipitation_probability_max"
           f"&timezone=Asia%2FShanghai")
    data = json.loads(urllib.request.urlopen(url).read())
    today = {k: v[0] for k, v in data["daily"].items()}   # daily 数组取今天
    cur = data["current"]
    return json.dumps({
        "天气": WEATHER_CODE_DESC.get(today["weather_code"], "未知天气码"),
        "当前温度_C": cur["temperature_2m"],
        "湿度_%": cur["relative_humidity_2m"],
        "今日最高_C": today["temperature_2m_max"],
        "今日最低_C": today["temperature_2m_min"],
        "日出": today["sunrise"][11:16],
        "日落": today["sunset"][11:16],
        "降雨概率_%": today["precipitation_probability_max"],
    }, ensure_ascii=False)      # ensure_ascii=False:中文原样输出,别变 \uXXXX

⚠️ 注意 schema 也跟着变了:city: string 变成了 latitude / longitude: number。工具的输入从"城市名"变成"经纬度"后,模型的负担也变了——它得自己把"北京"换算成坐标。这正是版本 4 里模型路由要配合的原因之一(有地理能力的大模型才能干这活)。真实项目里,更常见的是你提供"城市名 → 经纬度"的第二个工具。

4.3 模型硬路由:route_model

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def route_model(question: str) -> str:
    """模型硬路由:从用户输入里找模型名,找不到用默认。
    简单粗暴但够用——先让"选模型"这件事可复现,再谈智能路由。"""
    for name in ["kimi", "deepseek", "qwen", "gpt"]:
        if name in question.lower():
            return name
    return "deepseek"    # 默认


# 使用时:
llm = RealLLM(route_model(user_input))

为什么在 FC 循环外面加路由? 因为不同任务适合不同模型——简单对话用便宜快的,复杂推理用贵的。硬路由是第一步:规则写在明面上、结果可预测。等哪天规则不够用了,再让模型自己路由(那就是 auto_select,版本 5 的主角)。

4.4 🎯 tool_records:给 Agent 装"行车记录仪"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def fc_loop(question: str, llm: LLMClient, max_rounds: int = 3) -> dict:
    messages = [{"role": "user", "content": question}]
    tool_records = []                 # 🎯 新:记录每一次工具调用

    for _ in range(max_rounds):
        rep = llm.chat(messages, tools=TOOLS_SCHEMA)
        msg = rep.choices[0].message
        if not msg.tool_calls:
            return {
                "answer": msg.content,          # 最终答案
                "tool_records": tool_records,   # [] = 没调工具(模型猜的!)
            }
        messages.append(msg)
        for tc in msg.tool_calls:
            try:
                arg = json.loads(tc.function.arguments)
            except Exception as e:
                messages.append({"role": "tool", "tool_call_id": tc.id,
                                 "content": f"参数解析失败:{e}"})
                continue
            result = run_tool(tc.function.name, arg)
            tool_records.append({                # 🎯 每次调用都记一笔
                "tool": tc.function.name,
                "args": arg,
                "result": result,
            })
            messages.append({"role": "tool", "tool_call_id": tc.id,
                             "content": result})
    return {"answer": "达到最大轮数", "tool_records": tool_records}

返回值从字符串升级成字典,是这版最重要的设计决策:

1
2
3
4
5
6
7
8
out = fc_loop("天津天气怎么样", RealLLM("deepseek"))

if out["tool_records"]:
    # 正常:回答确实经过工具调用
    print("工具调用记录:", out["tool_records"])
else:
    # ⚠️ 红灯:模型没调任何工具就给了答案 —— 很可能在编!
    print("警告: 模型未调用任何工具, 回答可能为模型推断")

类比:tool_records 就是给 Agent 装的行车记录仪。车撞了(答错了),你有录像能查"它当时到底有没有看路(调工具)"。没有记录仪,你连"它在编"都分不出来。

🎯 一句话记忆:模型没调工具 ≠ 答对了,也可能是在猜。 把 tool_records 暴露出来,幻觉风险至少能被看见——看见是治理的第一步。


五、版本 5:prompt 工厂 + auto_select(把 W1 和 W2 焊在一起)

5.1 这版在干什么

W1 你写了四种 Prompt 模式(CoT / Few-shot / ReAct / ToT),还写了个路由器选模式。W2 你写完了 FC 闭环。

版本 5 = 路由器 + FC 的合体:一个 auto_select 让模型判断"这问题适合哪种模式",然后把对应模式的答案拼装成最终回复。这是"会选模式 + 会调工具"的第一次合流——它就是你 Agent 的雏形。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  用户输入
    │
    ▼
  auto_select(question)   ← LLM 判断:cot / few_shot / react / tot
    │
    ▼
  run_mode(mode, question)
    ├─ tot       → 生成 n 方案 → 打分评审 → 选最优(ToT 三连问)
    ├─ react     → fc_loop + 工具调用 + 行车记录仪检查
    ├─ few_shot  → 按示例模板抽取信息
    └─ cot       → 一步步思考再回答

5.2 基础设施:流式 + 思考链收集

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from openai import OpenAI
import os

client = OpenAI(api_key=os.getenv("DEEPSEEK_API_KEY"),
                base_url="https://api.deepseek.com")


def get_response(messages, **kwargs):
    """带思考链收集的流式请求。
    DeepSeek 的深度思考模型会把"内部推理"放在 reasoning_content 里,
    和最终 content 分开流式返回——这里两个都收。"""
    response = client.chat.completions.create(
        model=kwargs.get("model", "deepseek-v4-flash"),
        messages=messages,
        stream=True,
        reasoning_effort=kwargs.get("reasoning_effort", "low"),
        max_tokens=kwargs.get("max_tokens", 500),
    )
    reasoning_content, content = "", ""
    for chunk in response:
        delta = chunk.choices[0].delta
        if delta.reasoning_content:       # 思考过程(不展示给用户)
            reasoning_content += delta.reasoning_content
        if delta.content:                 # 最终回答(展示给用户)
            content += delta.content
    # 有些模型只给思考不给 content(比如纯推理题),那就把思考当答案
    if content:
        return content
    return reasoning_content

💡 reasoning_content 和 content 分流,是 DeepSeek 系列(reasoner)的特色——思考过程单独流式返回。这让"隐藏思维链"成了协议级能力,而不是 prompt 里求它"一步步想"。

5.3 模式工厂:四个 Prompt 构建器 + 一个特殊出口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def cot_prompt(question, system_prompt="你是一个逻辑缜密,思维严谨的推理助手"):
    """CoT:引导一步步思考再回答(适合数学/推理/公式推导)"""
    u = f"请一步步思考,再给出答案。\n问题:{question}"
    return [{"role": "system", "content": system_prompt},
            {"role": "user", "content": u}]


DEFAULT_EXAMPLES = [
    {"question": "我明天下午3点约了张医生复诊",
     "answer": "时间:明天下午3点;人物:张医生;事项:复诊"},
    {"question": "周五晚上和李总在望江楼吃饭",
     "answer": "时间:周五晚上;人物:李总;事项:吃饭"},
]

def few_shot_prompt(question, examples=DEFAULT_EXAMPLES,
                    system_prompt="你是擅长信息抽取的助手"):
    """Few-shot:先给 2 个"问题→格式化答案"的范例,再问真问题。
    适合固定模板的抽取任务(时间/人物/事项)。"""
    msgs = [{"role": "system", "content": system_prompt}]
    for ex in examples:
        msgs.append({"role": "user", "content": ex["question"]})
        msgs.append({"role": "assistant", "content": ex["answer"]})
    msgs.append({"role": "user", "content": question})
    return msgs


def react_prompt(question, system_prompt="你是一位有多种能力的小助手"):
    """ReAct:不是拼 prompt,而是直接进 fc_loop(W2 的主角)。
    工具调用模式的"提示词"就是 tools schema + 循环本身。"""
    out = fc_loop(question, RealLLM(route_model(question)))
    if out["tool_records"]:
        print("工具调用记录:", out["tool_records"])
    else:
        print("警告: 模型未调用任何工具, 回答可能为模型推断")
    return out["answer"]


def tot_solve(question, n=3):
    """ToT:方案生成 → 打分评审 → 选最优,三连问(W1 的内容)"""
    schemes = ask(
        f"请针对以下问题给出{n}种不同解决思路,编号列出:\n{question}\n",
        system_prompt="你是擅长发散思维的规划专家",
        max_tokens=600,
    )
    scores = ask(
        f"请对以下 {n} 个方案根据实行难度,风险评估逐一打分(1-10分),"
        f"并各用一句话说明理由:\n{schemes}",
        system_prompt="你是严格的评审专家,打分要客观",
        max_tokens=500,
    )
    best = ask(
        f"综合以下评分,选出最优方案,并给出具体实施步骤:\n{scores}",
        system_prompt="你是决策专家,直接给最终选择",
        max_tokens=600,
    )
    return schemes, scores, best

⚠️ 注意 react_prompt 的返回值:它是字符串(out["answer"]),而其他模式返回消息列表。为了让 run_mode 统一处理,版本 5 里 react 走了特判(见 5.5)。这种"接口不齐"是演进过程中的常态——先跑通,再谈统一。

5.4 调度总入口:BUILDERS 查表

1
2
BUILDERS = {"cot": cot_prompt, "few_shot": few_shot_prompt,
            "react": react_prompt}

react 也放进 BUILDERS 是为了让 auto_select 的匹配逻辑统一——但它在 run_mode 里被特判,因为它的返回类型和别人不一样(见下)。

5.5 auto_select + run_mode:会选模式的 Agent

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def auto_select(question: str) -> str:
    """让 LLM 判断这问题适合哪种模式,返回模式名。
    规则写在 prompt 里;识别用关键词兜底(tot→react→few_shot→cot)。
    注意匹配顺序:tot 最长,cot 最短——先匹配长的,避免 'cot' 误吞 'react'。"""
    content = (
        f"判断下面用户的问题适合哪种回答模式,输出:tot_solve 或 react_prompt "
        f"或 few_shot_prompt 或 cot_prompt\n"
        f"规则:需要一步步推理/数学解题/公式推导的输出 cot;"
        f"固定模板化抽取信息的输出 few_shot;"
        f"需要调工具(天气/计算等)的输出 react;"
        f"开放性问题/多方案对比找最优的输出 tot\n"
        f"问题:{question}"
    )
    text = get_response([{"role": "user", "content": content}],
                        max_tokens=50).lower()
    for mode in ["tot", "react", "few_shot", "cot"]:
        if mode in text:
            return mode
    print("[警告] 未识别到模式名,兜底 cot")
    return "cot"


def run_mode(mode: str, question: str, **kw):
    """按模式执行。react 和 tot 是特判(有副作用/多步),其余走 BUILDERS。"""
    if mode == "tot":
        schemes, scores, best = tot_solve(question, n=kw.get("n", 3))
        return f"【方案】\n{schemes}\n\n【评审】\n{scores}\n\n【决策】\n{best}"
    if mode == "react":
        return react_prompt(question)
    if mode not in BUILDERS:
        raise ValueError(f"未知模式:{mode},可选 {list(BUILDERS) + ['tot']}")
    msgs = BUILDERS[mode](question, **kw)
    return get_response(msgs, **kw)


# ── 主循环:选模式 → 执行 → 输出(Agent 的第一版驾驶舱)──
if __name__ == "__main__":
    while True:
        user_input = input("User: ")
        if user_input.lower() in ["exit", "quit"]:
            break
        mode = auto_select(user_input)
        print(f"[使用模式] {mode}")
        result = run_mode(mode, user_input)
        print("Agent:", result)

5.6 🎯 这版的地位:你第一次"让模型决定怎么答"

看主循环那三行——它不再是你选 prompt,而是模型自己选 prompt:

1
2
3
4
5
  版本 4 及以前:你写死一种方式 → 调用
  版本 5:      模型看问题 → 自己选 cot/few_shot/react/tot → 执行

  本质:把"怎么答"这个决策,也交给了模型。
  ← 这就是 Agent 与"调 API"的分界线

⚠️ 诚实提醒:auto_select 用"关键词兜底"而非纯靠 LLM,是有意的。纯靠 LLM 选模式,选错一次整个回答就废了;关键词兜底虽然笨,但可预测、可调试。等你把每种模式的触发规则摸清了,再放开让 LLM 全权决定也不迟。


六、五版演进总对照(一张表看懂)

版本解决什么新增的关键物一句话
v1 最小闭环FC 是什么tools schema + if tool_calls先让"点菜→做菜→上菜"转起来
v2 真 API + 分发单轮变多轮fc_loop 循环 + run_status 分发 + 出错回填循环自己转,工具挂了对话不挂
v3 MockLLM测试烧钱、不可复现LLMClient 抽象 + Fake* 结构 + 剧本假模型按剧本演戏,零成本测循环
v4 路由 + 记录模型会编答案route_model + tool_records行车记录仪:没调工具 = 可能在猜
v5 prompt 工厂一种方式答所有题auto_select + run_mode + BUILDERS让模型自己决定"怎么答"

演进主线其实是两条:

1
2
3
4
5
6
  纵向:让循环更皮实
    v1 if 单轮 → v2 for 多轮 → v3 可测 → v4 可查 → v5 会选

  横向:抽象边界越来越清晰
    tools(菜单)→ run_status(分发)→ LLMClient(引擎接口)
    → tool_records(记录)→ BUILDERS(模式注册表)

🎯 回头看,你其实在无意识地实践三层抽象:数据(messages/tools)、执行(分发/循环)、策略(路由/选模式)。这三层分开,是后面一切 Agent 工程的地基。


七、踩坑记

坑 1:手写 tools schema 结构记错 ⚠️

1
2
3
4
5
6
# ❌ 少了 "type": "function" 这层
{"function": {"name": "get_weather", "parameters": {...}}}

# ✅ 标准结构:type → function → name/description/parameters
{"type": "function", "function": {"name": "get_weather", "description": "...",
  "parameters": {"type": "object", "properties": {...}, "required": [...]}}}
  • 现象:请求 400 或模型不识别工具
  • 解法:对照官方文档逐层写。这也是为什么后来 LangChain 的 @tool 值钱——它从函数签名自动生成这一整坨,不用你手写(W4-D5 见)。

坑 2:tool_call_id 不匹配 → 400 ⚠️

  • 现象:下一轮请求报 insufficient tool messages 之类的错
  • 原因:模型发了 N 个 tool_calls,你回填了 M 条,或 id 抄错
  • 解法:每条 tool_calls 都必须有对应的一条 tool 消息,id 原样带回。多工具时注意 append 在 for 循环内部

坑 3:忘了把带 tool_calls 的 assistant 消息存回 messages ⚠️

  • 现象:模型第二轮好像"失忆",重复发同一个工具调用
  • 原因:messages.append(msg) 被跳过,模型不知道自己点过菜
  • 解法:先 append 再执行工具,顺序不能反

坑 4:ast.literal_eval 不认表达式 🔴

  • 现象:calculator("1+1") 抛 ValueError
  • 原因:literal_eval 只解析字面量(数字/字符串/列表/dict),1+1 是表达式
  • 解法:白名单 AST 求值(见 3.5),别用裸 eval——它会执行任意代码

坑 5:工具执行出错直接 raise,把整个对话搞崩 ⚠️

  • 现象:一个工具挂了,用户只看到 traceback
  • 解法:run_status 用 try/except 兜住,把错误信息当结果回填——模型看到错误自己会修正
  • 🎯 原则:工具是增强项不是可用性依赖,工具挂了对话得继续

坑 6:urllib.parse.quote 忘 import(隐式依赖)⚠️

  • 现象:本地能跑、换环境就 NameError: name 'urllib' is not defined(或半可用)
  • 原因:import urllib.request 内部顺带加载了 urllib.parse,你"蹭"到了它——这是隐式依赖,脆弱
  • 解法:用哪个子模块就显式 import urllib.parse / import urllib.request

坑 7:模型没调工具就给了答案,你还真信了 🔴

  • 现象:问"北京天气",模型答"今天晴,25℃",但它压根没调工具
  • 原因:模型不会的也会编(幻觉),没调工具 = 没有真实数据来源
  • 解法:fc_loop 返回 tool_records,空记录时打警告——先看见幻觉,才能治理幻觉

坑 8:把"选模式"也交给 LLM,选错一次全废 ⚠️

  • 现象:auto_select 判断失误,数学题走了聊天模式
  • 解法:先用关键词兜底规则(可预测、可调试),摸清触发边界后再放开给 LLM
  • 💡 规则的优先级:能写死的别让模型猜——模型只做规则做不了的事

八、速查卡片(复习直接看这)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# ===== FC 闭环四步 =====
# ① 注册:tools = [{"type": "function", "function": {name/description/parameters}}]
# ② 模型:res = client.chat.completions.create(..., tools=tools)  # 不传 tools 永远不点菜
# ③ 执行:result = TOOLS_FUNC[name](**json.loads(tc.function.arguments))
# ④ 回填:messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})

# ===== fc_loop 骨架(五要素)=====
def fc_loop(question: str, llm: LLMClient, max_rounds: int = 3):
    messages = [{"role": "user", "content": question}]
    tool_records = []
    for _ in range(max_rounds):                     # ② 有界循环
        rep = llm.chat(messages, tools=TOOLS_SCHEMA)   # 引擎可替换:真/假都行
        msg = rep.choices[0].message
        if not msg.tool_calls:                      # ③ 没点菜 → 直接回答
            return {"answer": msg.content, "tool_records": tool_records}
        messages.append(msg)                        # 存下点菜单(别忘了!)
        for tc in msg.tool_calls:
            try:
                arg = json.loads(tc.function.arguments)
            except Exception as e:
                messages.append({"role": "tool", "tool_call_id": tc.id,
                                 "content": f"参数解析失败:{e}"})
                continue
            try:
                result = TOOLS_FUNC[tc.function.name](**arg)
            except Exception as e:
                result = f"工具执行失败:{e}"         # 出错也回填
            tool_records.append({"tool": tc.function.name, "args": arg,
                                 "result": result})
            messages.append({"role": "tool", "tool_call_id": tc.id,
                             "content": result})     # ✅ for 内回填
    return {"answer": "达到最大轮数", "tool_records": tool_records}

# ===== 三条铁律 =====
# 1. 发几个 tool_calls 就回填几条 tool 消息,id 一一对应
# 2. 出错也回填错误信息,让模型自纠,别 raise
# 3. 没调工具就给的答案,默认当"猜测"处理(tool_records 留痕)

九、一句话总结

Function Calling 就是"模型只会点菜、不会做菜"这件事的解法:tools 菜单注册、模型输出 tool_calls 点菜单、你的代码进厨房执行、结果回填上菜——循环到模型直接给答案为止。

五版演进你做的事其实是一条线:

1
2
3
4
5
  先把闭环转起来(v1)
  → 让循环自己转、出错不崩(v2)
  → 造个假模型零成本测循环(v3)
  → 装上行车记录仪防幻觉(v4)
  → 让模型自己选怎么答(v5)

回头看,抽象边界在每一版里悄悄清晰:菜单(schema)、厨房(分发器)、引擎(LLMClient)、记录(tool_records)、策略(路由/BUILDERS)。这三五层分开的那一刻,你已经不是在"调 API",而是在"组装 Agent"了。

再往后看两件事的伏笔:

  1. run_status + 出错回填 → W4 的 ToolMessage 失败回填、W5 的 ToolNode(把 for 循环替你写了)
  2. tool_records → W5 的 stream 看节点流转(同一个"看得见才可信"的思路)

下一篇:W3 MemoryManager——现在这辆车会调工具了,但它没有记忆。下一周给它装上短期窗口、长期向量库和滚动压缩。


Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 Stack 由 Jimmy 设计