函数调用与结构化输出:让模型吐出"能程序处理的东西"
Day 08 我们见过"原生函数调用循环",Day 50 的 call 里也传了 tools 和 response_model——但都没细看 LLM 层内部怎么处理。今天补齐这块:工具怎么从 CrewAI 的 BaseTool 转成 OpenAI 的 function schema、请求参数里怎么带上它们、模型点名某个函数后 _handle_tool_call 怎么真去执行、以及 response_format 怎么逼模型吐合法 JSON、_validate_structured_output 怎么把它校验成 Pydantic 对象。这是"让 LLM 从聊天玩具变成可编程组件"的关键一层。
痛点:模型只会吐文本,程序要的是数据
Action: search 再正则抠(Day 09 文本 ReAct),模型很容易写错格式。同理,你想让任务输出一个 {"name":..., "age":...} 的对象,模型却可能在 JSON 前后加一句"好的,这是结果:"导致解析崩。怎么让模型稳定吐出程序能直接吃的结构?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") 真跑一遍。整个过程模型没写一行代码,只是"填表"。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"这个名字,你得靠这张字典把名字换回真正能执行的函数对象。名字是给模型看的,函数是给程序跑的,两者靠这张表挂钩。请求参数里怎么带 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 会报错,而"根本不带这个键"才是"没有工具"的正确表达。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,但这点开销完全可忽略。把"重复的条件判断"收敛成"一次统一过滤",是消除样板代码的常用手法。_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,不崩。结构化输出: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。从"读一段话"变成"取一个字段"。effective_response_model = response_model or self.response_format——既支持"每次调用临时指定"(response_model 参数),也支持"创建 LLM 时固定"(response_format 字段),临时的优先级更高。_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 可能抠过头,属于已知的简单取舍。工具执行的异常:错误也要能喂回模型
回看 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,让失败事件能和对应的调用开始事件对上号,便于追踪一次完整调用的生死。边界 + 今日小结
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'))
"
LLM_CONTEXT_WINDOW_SIZES 表、get_context_window_size 为什么乘 0.85、超长时 handle_context_length 怎么自动摘要压缩历史。