Day 51 / 共 60 天 · 阶段8 LLM 与集成

函数调用与结构化输出:让模型吐出"能程序处理的东西"

Day 08 我们见过"原生函数调用循环",Day 50 的 call 里也传了 toolsresponse_model——但都没细看 LLM 层内部怎么处理。今天补齐这块:工具怎么从 CrewAI 的 BaseTool 转成 OpenAI 的 function schema、请求参数里怎么带上它们、模型点名某个函数后 _handle_tool_call 怎么真去执行、以及 response_format 怎么逼模型吐合法 JSON、_validate_structured_output 怎么把它校验成 Pydantic 对象。这是"让 LLM 从聊天玩具变成可编程组件"的关键一层。

📍 你在 60 天里的位置(阶段8 LLM 与集成 · 共 6 天)
D49 LLM 抽象 D50 provider 适配 D51 函数调用/结构化 D52 上下文窗口 D53 token/成本 D54 遥测 阶段9 进阶
💡 先用一个类比兜住今天 普通对话就像"随便聊",你说啥它答啥,答的是自由散文。但程序需要的是填表格:要么"你想调用哪个功能、参数填什么"(函数调用 = 填一张"我要用 XX 工具,参数是 YY"的表),要么"把结果按这个字段格式给我"(结构化输出 = 填一张固定列的表)。函数调用和结构化输出,本质都是"给模型一张表格模板,逼它按格填,而不是自由发挥"。今天看框架怎么发这张模板、怎么验收填好的表。
L01

痛点:模型只会吐文本,程序要的是数据

🤔 痛点LLM 天生只会输出一段文字。可 Agent 要"调用 search 工具、参数是 SF weather"——程序怎么知道模型想调哪个函数、参数是什么?如果靠让模型写 Action: search 再正则抠(Day 09 文本 ReAct),模型很容易写错格式。同理,你想让任务输出一个 {"name":..., "age":...} 的对象,模型却可能在 JSON 前后加一句"好的,这是结果:"导致解析崩。怎么让模型稳定吐出程序能直接吃的结构?
💡 一句话本质 两条路都靠"把 Python 的类型定义翻译成模型能懂的 JSON Schema",再让模型按 schema 填。函数调用:把每个工具的参数模型转成 OpenAI function schema,随请求发给模型;模型返回结构化的 tool_call,框架用 _handle_tool_call 解析参数、真去执行函数。结构化输出:把目标 Pydantic 类作为 response_format 发给模型,拿回文本后用 _validate_structured_output 校验成对象。核心都是:Python 类型 → JSON Schema → 约束模型 → 验收回填。
大白话你有个函数 def search(query: str),框架自动读出它"要一个叫 query 的字符串参数",翻成一张 schema 表递给模型。模型看懂了,回你"我要调 search,query 填 'SF weather'"。框架收到这张填好的表,就 search(query="SF weather") 真跑一遍。整个过程模型没写一行代码,只是"填表"。
L02

convert_tools_to_openai_schema:工具变表格模板

第一步,把 CrewAI 工具翻译成 OpenAI 格式(utilities/agent_utils.py:154):

# utilities/agent_utils.py:154
def convert_tools_to_openai_schema(tools):
    """Convert CrewAI tools to OpenAI function calling format. ...
    Returns: (schema 列表, 名字→可调用, 名字→原始工具对象)"""
    openai_tools, available_functions, tool_name_mapping = [], {}, {}
    for tool in tools:
        parameters = {}
        if hasattr(tool, "args_schema") and tool.args_schema is not None:
            schema_output = generate_model_description(tool.args_schema, strip_null_types=False)
            parameters = schema_output.get("json_schema", {}).get("schema", {})
            parameters.pop("title", None); parameters.pop("description", None)
        description = tool.description
        if "Tool Description:" in description:
            description = description.split("Tool Description:")[-1].strip()  # 抠出纯描述
        sanitized_name = sanitize_tool_name(tool.name)          # 清洗成合法函数名
        if sanitized_name in available_functions:               # ★同名冲突→加后缀
            counter = 2
            candidate = sanitize_tool_name(f"{sanitized_name}_{counter}")
            while candidate in available_functions:
                counter += 1; candidate = sanitize_tool_name(f"{sanitized_name}_{counter}")
            sanitized_name = candidate
        schema = {"type": "function", "function": {
            "name": sanitized_name, "description": description,
            "parameters": parameters, "strict": True}}          # strict=严格模式
tool.args_schema每个工具带一个 Pydantic 参数模型(D28 讲过),这里读它生成 JSON Schema——工具"要什么参数"就是从这翻译来的。
pop("title"/"description")删掉 schema 里的冗余字段,只留模型真正需要看的参数结构,减少 token。
抠 "Tool Description:"BaseTool 的 description 被格式化成一大串,这里只取真正的描述部分给模型。
同名冲突加后缀★边界:两个工具重名会让模型点名时无法区分,自动改成 xxx_2 / xxx_3,保证名字唯一。
"strict": True开启 OpenAI 严格模式——强制模型返回的参数完全符合 schema,不许多字段少字段。
返回三元组schema(发给模型)、available_functions(名字→真函数,执行时查)、name_mapping(名字→原工具)。一次转换喂三处。
返回值里的 available_functions 是关键的"回执表"——模型只会告诉你"调 search"这个名字,你得靠这张字典把名字换回真正能执行的函数对象。名字是给模型看的,函数是给程序跑的,两者靠这张表挂钩。
数据结构:一次转换,喂出三张表 BaseTool(含 args_schema) convert_tools_to_openai_schema ① openai_tools [{type:function, function:{name,params, strict:True}}] → 发给模型 ② available_functions {"search": <真函数>} → 执行时按名字查 ③ tool_name_mapping {"search": <原工具对象>} → 回溯元信息
图注:一个工具对象转出三张表——schema 给模型看,functions 供执行查,mapping 留元信息。
L03

请求参数里怎么带 tools 和 response_format

转好的 schema,通过 _prepare_completion_params 塞进请求(llm.py:748):

# llm.py:748
def _prepare_completion_params(self, messages, tools=None, skip_file_processing=False):
    ...
    params = {
        "model": self.model,
        "messages": formatted_messages,
        "temperature": self.temperature,
        "max_tokens": self.max_tokens or self.max_completion_tokens,
        "response_format": self.response_format,   # ★结构化输出:目标 Pydantic 类
        "stop": (self.stop_sequences or None) if self.supports_stop_words() else None,
        "tools": tools,                            # ★函数调用:工具 schema 列表
        "reasoning_effort": self.reasoning_effort,
        **self.additional_params,
    }
    return {k: v for k, v in params.items() if v is not None}   # ★去掉所有 None
"tools": tools把 L02 转好的 schema 列表放进请求——这就是"发表格模板给模型"。
"response_format": self.response_format结构化输出的旋钮:传一个 Pydantic 类,底层 API 会约束模型输出符合它的 JSON。
stop 受能力探测保护只有 supports_stop_words()(D49)为真才带 stop——不支持的模型带了会报错。
最后一行过滤 None★重要技巧:把所有值为 None 的键删掉再发。因为很多 API 收到 tools=None 会报错,而"根本不带这个键"才是"没有工具"的正确表达。
💡 设计取舍①:为什么组装一个"全字段 dict"再过滤 None,而不是按需拼? 朴素做法if self.temperature is not None: params["temperature"]=... 一堆 if,代码又长又容易漏。源码做法:先无脑把所有字段塞进 dict(哪怕是 None),最后一行 {k:v for k,v in params.items() if v is not None} 统一清洗。可读性和防漏性都赢了——所有支持的参数一目了然列在一处,"未设置就不发"的规则也集中在一行。代价是构造了一个含 None 的临时 dict,但这点开销完全可忽略。把"重复的条件判断"收敛成"一次统一过滤",是消除样板代码的常用手法。
L04

_handle_tool_call:模型点名后真去执行

模型返回 tool_call 后,这个方法负责"解析参数 + 执行函数"(llm.py:1734):

# llm.py:1734
def _handle_tool_call(self, tool_calls, available_functions=None, from_task=None, from_agent=None):
    if not tool_calls or not available_functions:
        return None
    tool_call = tool_calls[0]                                    # ★只取第一个
    function_name = sanitize_tool_name(tool_call.function.name)
    function_args = {}
    if function_name in available_functions:
        try:
            function_args = json.loads(tool_call.function.arguments)  # ① 解析参数 JSON
            fn = available_functions[function_name]                   # ② 名字换回真函数
            crewai_event_bus.emit(self, event=ToolUsageStartedEvent(...))  # ③ 广播开始
            result = fn(**function_args)                              # ④ ★真正执行
            crewai_event_bus.emit(self, event=ToolUsageFinishedEvent(output=result, ...))
            self._handle_emit_call_events(response=result, call_type=LLMCallType.TOOL_CALL, ...)
            return result                                            # ⑤ 返回结果
        except Exception as e:
            logging.error(f"Error executing function '{function_name}': {e}")
            crewai_event_bus.emit(self, event=LLMCallFailedEvent(...))
            crewai_event_bus.emit(self, event=ToolUsageErrorEvent(...))   # 失败也广播
    return None
tool_calls[0] 只取第一个★呼应 Day 08:LLM 层这里也是默认只处理第一个工具调用,顺序执行 + 反思,而非一次全跑。
json.loads(arguments)模型返回的参数是 JSON 字符串,要 parse 成 dict 才能当关键字参数用。
available_functions[name]用 L02 那张"名字→函数"表,把模型说的名字换成真能跑的函数。
fn(**function_args)★核心一行:把解析出的参数展开成关键字参数,真正调用函数。search 就在这一刻跑起来。
三个事件广播开始/完成/失败都往事件总线发——遥测、UI、日志靠这些事件感知工具执行(D54)。
if name in available_functions★边界:模型可能幻觉出一个不存在的工具名,这里先检查存在才执行,不存在直接返回 None,不崩。
控制流:一次函数调用的往返 工具→schema 发出 模型返回 tool_call json.loads 解析参数 name→真函数 fn(**args) 执行 result 返回上层 全程广播 ToolUsageStarted / Finished / Error 事件
图注:发 schema → 模型点名 → 解析参数 → 名字换函数 → 执行 → 结果回上层,全程事件可观测。
L05

结构化输出:response_format 的两种形态

结构化输出的字段类型很讲究(llm.py:379,也见 base_llm.py:69):

# llm.py:379
response_format: JsonResponseFormat | type[BaseModel] | None = None

# base_llm.py:69 —— JsonResponseFormat 是一个 TypedDict
class JsonResponseFormat(TypedDict):
    type: str            # 通常 "json_object" / "json_schema"
    ...
type[BaseModel]最常用:直接传一个 Pydantic 类(如 class Person(BaseModel): name:str; age:int),框架自动生成 schema 约束模型。
JsonResponseFormat (TypedDict)低层形态:直接给 OpenAI 那种 {"type":"json_object"} 的原始字典,适合高级用户手工控制。
| None不设 = 普通自由文本输出。三态清晰。
📝 例子:结构化输出怎么用 定义 class Weather(BaseModel): city:str; temp_c:int,然后 llm = LLM(model="gpt-4o", response_format=Weather)
问"SF 现在多少度",模型不会回"旧金山大约 18 摄氏度哦~",而是回 {"city":"SF","temp_c":18}
框架再用 L06 的 _validate_structured_output 把它变成一个真正的 Weather(city="SF", temp_c=18) 对象——你的程序可以直接 result.temp_c从"读一段话"变成"取一个字段"。
这里也回收了 Day 50 的伏笔:Anthropic 适配器里有 effective_response_model = response_model or self.response_format——既支持"每次调用临时指定"(response_model 参数),也支持"创建 LLM 时固定"(response_format 字段),临时的优先级更高。
L06

_validate_structured_output:验收模型填的表

模型吐回来的文本,要经过这道校验才能变成对象(base_llm.py:897):

# base_llm.py:897(配合 :77 的 _JSON_EXTRACTION_PATTERN = re.compile(r"\{.*}", re.DOTALL))
def _validate_structured_output(response, response_format):
    if response_format is None:
        return response                              # 没要求结构化 → 原样返回
    try:
        if response.strip().startswith("{") or response.strip().startswith("["):
            data = json.loads(response)              # ① 整段就是 JSON → 直接 parse
            return response_format.model_validate(data)
        json_match = _JSON_EXTRACTION_PATTERN.search(response)   # ② 从夹带文字里抠 JSON
        if json_match:
            data = json.loads(json_match.group())
            return response_format.model_validate(data)
        raise ValueError("No JSON found in response")            # ③ 压根没 JSON
    except (json.JSONDecodeError, ValueError) as e:
        logging.warning(f"Failed to parse structured output: {e}")
        raise ValueError(f"Failed to parse response into {response_format.__name__}: {e}") from e
response_format is None → 原样返回没开结构化就啥也不做,透明放行普通文本。
startswith("{") or "["快路径:整段本来就是 JSON(对象或数组),直接 json.loads
_JSON_EXTRACTION_PATTERN★慢路径兜底:模型有时会加"好的,结果如下:",正则 \{.*} 把中间那坨 JSON 抠出来。re.DOTALL. 也能跨行匹配。
model_validate(data)★关键:用 Pydantic 校验 + 构造对象。字段缺失/类型不对都会在这里报错——保证你拿到的对象一定合规。
raise ...from e失败时抛一个带目标类名的清晰错误,并用 from e 保留原始异常链,方便定位。
💡 设计取舍②:既然开了 response_format,为什么还要正则兜底抠 JSON? 理论上开了结构化输出,模型就该只吐纯 JSON。但现实是:并非所有模型/provider 都严格执行 response_format——本地小模型、某些兼容网关会打折扣,可能还是夹带客套话。如果只写"整段必须是 JSON"的快路径,遇到这些模型就会频繁失败。所以源码加了正则兜底:先信任(快路径),失败再尽力抢救(慢路径),都不行才明确报错。这是"对上游宽容、对下游负责"的防御式解析——你不能控制模型多嘴,但你能尽量把有用的部分捞出来。代价是正则 \{.*} 贪婪匹配对嵌套 JSON 可能抠过头,属于已知的简单取舍。
L07

工具执行的异常:错误也要能喂回模型

回看 L04 的 except 分支和 base_llm 里的执行封装(base_llm.py:714):

# base_llm.py:714
def _handle_tool_execution(self, function_name, function_args, available_functions,
                           from_task=None, from_agent=None) -> str | None:
    """Handle tool execution with proper event emission."""
    ...  # 执行工具,并把开始/完成/错误统一包装成事件

# llm.py:1734 里工具执行失败时的处理(L04 的 except)
except Exception as e:
    fn = available_functions.get(function_name, lambda: None)
    logging.error(f"Error executing function '{function_name}': {e}")
    crewai_event_bus.emit(self, event=LLMCallFailedEvent(
        error=f"Tool execution error: {e!s}", from_task=from_task, from_agent=from_agent,
        call_id=get_current_call_id()))
    crewai_event_bus.emit(self, event=ToolUsageErrorEvent(
        tool_name=function_name, tool_args=function_args,
        error=f"Tool execution error: {e!s}", ...))
_handle_tool_execution基类里的统一封装——所有 provider 执行工具都走这,保证"开始/完成/错误"事件语义一致。
捕获 Exception工具是用户写的代码,什么错都可能抛(网络、除零、KeyError)。这里全捕获,避免一个工具崩掉整个 Agent。
ToolUsageErrorEvent★把错误也当成一种"结果"广播出去——上层执行器可以把错误信息喂回模型,让它换个参数重试(呼应 Day 08 的自愈)。
get_current_call_id()带上当前调用 ID,让失败事件能和对应的调用开始事件对上号,便于追踪一次完整调用的生死。
大白话工具执行出错,框架不是"当场崩溃",而是"把错误记下来、广播出去、返回 None"。这样上层就能把"你调 search 报了 XX 错"这句话喂回给模型,模型说不定换个查询词就成功了。错误在这里是"反馈",不是"终点"。
L08

边界 + 今日小结

⚠️ 边界:结构化输出 ≠ 函数调用,别混用错场景 两者都"让模型给结构",但用途不同,别用错:函数调用是"模型决定要不要调哪个工具"——控制权在模型,适合 Agent 自主决策(查资料、算数)。结构化输出是"模型必须把最终答案按这个格式给我"——控制权在你,适合把任务结果落成固定 schema(Day 15 的 output_pydantic 就是它)。踩坑点:有人想"让模型输出一个对象"却去配了一堆工具,或者想"让模型自主调工具"却用了 response_format 逼死格式。记住:要它选动作用 tools,要它交表格用 response_format。另外 strict:True 虽好,但个别老模型不支持严格模式,遇到报错就得关掉——这也是 Day 49 能力探测存在的意义。

👶 小白:模型明明开了 response_format,为什么有时还是解析失败?

👨‍🏫 老师:因为 response_format 是"请求约束",不是"绝对保证"。不同 provider 支持程度不一:OpenAI/Anthropic 新模型执行得好,本地小模型或某些网关可能只是"尽量"。所以 L06 才要正则兜底 + Pydantic 校验双保险。真频繁失败,要么换个更听话的模型,要么把 schema 简化(字段越少越复杂嵌套越少,模型越不容易填错)。

🧠 今天你应该能回答

  • 函数调用和结构化输出的共同本质是什么?
  • 工具怎么从 BaseTool 转成 OpenAI schema?同名冲突怎么办?
  • available_functions 这张"名字→函数"表为什么必不可少?
  • 为什么请求参数要"全字段 dict 再过滤 None"?
  • _validate_structured_output 的快路径/慢路径分别处理什么?
  • 函数调用 vs 结构化输出,各自适合什么场景?

✋ 10 分钟动手

P=lib/crewai/src/crewai
sed -n '154,220p'  $P/utilities/agent_utils.py     # 工具转 schema
sed -n '748,800p'  $P/llm.py                        # 请求参数组装
sed -n '1734,1819p' $P/llm.py                       # _handle_tool_call 执行
sed -n '897,934p'  $P/llms/base_llm.py              # 结构化输出校验
# 亲手试结构化输出
python -c "
from pydantic import BaseModel
from crewai import LLM
class W(BaseModel):
    city: str; temp_c: int
print(LLM(model='gpt-4o', response_format=W).call('SF is about 18C right now'))
"
明日预告 · Day 52:今天工具 schema、结构化输出都会往对话里塞更多内容——对话越长越可能撑爆模型的"脑容量"。明天讲上下文窗口管理LLM_CONTEXT_WINDOW_SIZES 表、get_context_window_size 为什么乘 0.85、超长时 handle_context_length 怎么自动摘要压缩历史。
← Day 50 provider 适配 Day 52 · 上下文窗口管理 →