函数调用的完整往返:微软生成式 AI 入门课第 11 课的工具定义与回传

2026-08-18

你让模型「把这段文字里的姓名、专业、学校、成绩、社团抽成 JSON」,它确实给你 JSON 了。问题是同一个提示词跑两遍,成绩那一项一会儿是 3.7,一会儿是 3.7 GPAgenerative-ai-for-beginners 第 11 课的笔记本就是拿这个场景开的头——11-integrating-with-function-calling/python/oai-assignment.ipynb 里先用两段学生描述(student_1_descriptionstudent_2_description)和两段结构完全相同的提示词 prompt1prompt2 跑一遍普通生成,然后在正文里写明:多跑几次,Grades 这个属性的格式可能是 3.7,也可能是 3.7 GPA

下游要把这个值写进数据库,这种摇摆就是事故源。这一课给的解法不是「把提示词写得更狠」,而是换一条链路:把你希望模型填的那些槽位,用一份 schema 交给模型,让它返回结构化的调用意图,你自己去执行函数。笔记本里有一句话值得单拎出来——模型并不会真的去调用或运行任何函数,它只是按你给的结构返回一个「该调哪个函数、参数是什么」的描述,真正执行的是你的代码。

前置条件

这一课的 Python 笔记本有两个版本,走的是两条不同的 provider 路线,别拿混:oai-assignment.ipynb 对应 OpenAI,aoai-assignment.ipynb 对应 Azure OpenAI。00-course-setup/03-providers.md 里写明了这个命名约定:文件名带 oai 标记的需要 OpenAI 的 endpoint 与 key,带 aoai 的需要 Azure OpenAI 的 endpoint 与 key。本文只顺着 oai-assignment.ipynb 这一条线讲。

环境这边,00-course-setup/02-setup-local.md 的先决条件表里对 Python 列了最低版本要求(具体数值以仓库当前那份文档为准,随版本会变)。仓库给的原生路径是建虚拟环境再装依赖:

python -m venv .venv          # make one
source .venv/bin/activate     # macOS / Linux
.\.venv\Scripts\activate      # Windows PowerShell
pip install -r requirements.txt

Windows 侧有两处值得先看一眼。一是激活脚本是上面那行 .\.venv\Scripts\activate,不是 macOS/Linux 的 source;二是同一份文档的排障表里专门列了一条:pip 在 Windows 上构建 wheel 失败时,先跑 pip install --upgrade pip setuptools wheel 再重试。这条是仓库文档自己写的,不是我们的经验之谈。

依赖方面,根目录 requirements.txt 里与本课直接相关的是 openaipython-dotenv 两个包(版本约束以仓库当前的 requirements.txt 为准),笔记本里建客户端那个代码单元也正是 from openai import OpenAIfrom dotenv import load_dotenv 起手。凭据走 .env00-course-setup/03-providers.md 让你把根目录的 .env.copy 复制成 .env(文档里给的命令是 cp .env.copy .env),OpenAI 这条线要填的是 OPENAI_API_KEY.env 已被 gitignore。注意这条线的调用会走到云端服务,密钥与被发送的文本都要自己把好边界。

oai-assignment.ipynb 里客户端就是裸的 client = OpenAI(),随后 deployment="gpt-5-mini"——这是仓库里写死的示例值,你的账号下未必是这个,改成自己的即可。作为对照,aoai-assignment.ipynb 那条线不是这么建的:它把 OpenAI 客户端的 base_url 指到 f"{os.environ['AZURE_OPENAI_ENDPOINT'].rstrip('/')}/openai/v1/"deployment 取自 AZURE_OPENAI_DEPLOYMENT。两条线除了这一个单元格,后面的代码几乎一致,但配置千万别混着抄。

第一段:工具 schema 里每个字段管什么

第 11 课只定义了一个工具 search_courses。笔记本里的定义原样如下:

# The Responses API uses a flat tool format: name/description/parameters at the top level
functions = [
   {
      "type":"function",
      "name":"search_courses",
      "description":"Retrieves courses from the search index based on the parameters provided",
      "parameters":{
         "type":"object",
         "properties":{
            "role":{
               "type":"string",
               "description":"The role of the learner (i.e. developer, data scientist, student, etc.)"
            },
            "product":{
               "type":"string",
               "description":"The product that the lesson is covering (i.e. Azure, Power BI, etc.)"
            },
            "level":{
               "type":"string",
               "description":"The level of experience the learner has prior to taking the course (i.e. beginner, intermediate, advanced)"
            }
         },
         "required":[
            "role"
         ]
      }
   }
]

注意顶层是扁平的——typenamedescriptionparameters 四个键平铺在同一层,章节 README 把这个称作 flat Responses API format。这一点和很多人记忆里「外面套一层 function」的旧写法不一样,照旧记忆写会对不上。

parameters 里就是一份 JSON Schema:typeobjectproperties 下每个键名就是参数名,每个参数各自有 typedescription。笔记本紧接着的那段 Definitions 说明在拆解这份结构时特意点了一句:参数名是由 properties 的键隐式定义的,没有单独的 name 字段。description 这一栏别糊弄,它是模型判断该往这个槽位填什么的唯一依据。

required 是可选项,这里只写了 role。这个细节后面会咬人,先记住。

第二段:模型返回的是什么

发起调用时,工具通过 tools 传入,选择权通过 tool_choice 交出去:

response = client.responses.create(model=deployment,
                                        input=messages,
                                        tools=functions,
                                        tool_choice="auto",
                                        store=False)

print(response.output)

这里有个坑要提前说破:oai-assignment.ipynb 里紧挨着这段代码的说明文字仍然写着「adding functions to the request,即 functions=functions」,以及「把 function_call 设成 auto」——而代码里实际写的是 tools=functionstool_choice="auto"。章节 README 的对应段落已经改成了 toolstool_choice,笔记本的说明文字还停在旧参数名上。这种正文与代码不一致的地方,以代码为准。

返回值不再是一句话,而是 response.output 这个列表。README 给出的 function_call 项形状是:

{
  "type": "function_call",
  "name": "search_courses",
  "call_id": "call_abc123",
  "arguments": "{\n  \"role\": \"student\",\n  \"product\": \"Azure\",\n  \"level\": \"beginner\"\n}"
}

四个字段各有各的用处:type 用来把它从 output 的其它项里筛出来,name 告诉你该调哪个本地函数,arguments字符串形式的 JSON(所以后面必须 json.loads),而 call_id 是回传结果时的配对凭证——少了它,模型不知道你交回来的这份输出对应哪一次调用。

顺带一提,同一个笔记本里另有一段说明文字展示的返回形状是 {"role": "assistant", "function_call": {...}},既没有 type 也没有 call_id。它和 README 里那份对不上,而下游代码 [item for item in response.output if item.type == "function_call"] 依赖的是 README 那一份。

筛选这一步的代码是:

# Extract the function call items from the response output
tool_calls = [item for item in response.output if item.type == "function_call"]

第三段:执行,然后按 function_call_output 回传

本地函数就是一个普通的 Python 函数,去打 Microsoft Learn 的目录 API:

def search_courses(role, product, level):
    url = "https://learn.microsoft.com/api/catalog/"
    params = {
        "role": role,
        "product": product,
        "level": level
    }
    response = requests.get(url, params=params)
    modules = response.json()["modules"]
    results = []
    for module in modules[:5]:
        title = module["title"]
        url = module["url"]
        results.append({"title": title, "url": url})
    return str(results)

模型名到函数对象的映射是手写的一张字典 available_functions = {"search_courses": search_courses},然后 function_args = json.loads(tool_call.arguments)function_response = function_to_call(**function_args)——参数是解包传进去的。

回传这一步的格式是本课的关键:

    messages.extend(response.output)  # adding the model's function_call item(s)
    messages.append( # adding function response to messages
        {
            "type": "function_call_output",
            "call_id": tool_call.call_id,
            "output": function_response,
        }
    )

三个字段:type 固定是 function_call_outputcall_id 原样取自模型返回的那一项,output 是你的函数结果。README 在同一段代码的注释里写明了顺序约束——模型的 function_call 项必须先于它的输出被追加进去。另外注意 search_courses 结尾是 return str(results),回传的是字符串而不是 list,笔记本里紧跟着 print(type(function_response)) 就是让你看这一点。

最后把补全后的 messages 再发一次,拿自然语言回复:

second_response = client.responses.create(
    input=messages,
    model=deployment,
    tools=functions,
    tool_choice="auto",
    store=False,
        )

print(second_response.output_text)

以上片段均原样取自仓库文件,未经实测,以仓库最新代码为准。

边界:几处照抄会崩的地方

required 和函数签名对不上。 schema 里 required 只列了 role,意味着 productlevel 允许缺席;而 def search_courses(role, product, level) 这三个形参都没有默认值。把这两处放在一起看:模型只填了 role 时,function_to_call(**function_args) 就会因为缺少位置参数而抛错。课程的 Assignment 一节也确实把「当函数调用或 API 调用没有返回合适课程时做错误处理」列成了留给读者的练习——这段示例代码本身没有任何 try/except,response.json()["modules"] 也没做键存在性检查。

只处理了第一个调用项。 笔记本里取的是 tool_call = tool_calls[0],而章节 README 的同一段代码写的是 for tool_call in tool_calls: 遍历全部。同一课两处不一致,模型一次返回多个 function_call 项时,照笔记本抄的那份会丢掉后面的。

两处调用参数也不完全一样。 README 的第二次调用里多了 temperature=0,笔记本的第二次调用没有这个参数。另外两个笔记本的每一次 responses.create 都显式传了 store=False,这是仓库代码里的写法,它的具体语义请以你所用服务商的接口文档为准,课程正文没有展开。

工具定义是要占额度的。 笔记本在定义函数那一节标了「Important」:函数会被包含进发给模型的系统消息里,并计入你可用的 token 里。工具定义写得越长越占,这一点在你堆到十几个工具时才会显形。

其它语言路线。 这一课目录下还有 js-githubmodels/app.jstypescript/function-app 两个非 Python 版本。00-course-setup/03-providers.md 提到 GitHub Models 正在退役、由 Microsoft Foundry Models 接手,走这条线之前建议先读那份 provider 说明,别直接照着旧配置填。

怎么验证你配对了

按这四步逐段确认,不要一口气跑到底:

第一步,print(response.output) 之后确认列表里确实有 item.type == "function_call" 的项。如果一个都没有,说明模型选择了直接回话——检查 tool_choice 是否传了、description 是不是写得太含糊。

第二步,json.loads(tool_call.arguments) 能正常解析成 dict,并且键名与你 properties 里的键名逐字对得上。

第三步,print(type(function_response)) 看到的应当是 str,因为 search_coursesreturn str(results)

第四步,笔记本在发第二次请求前先 print(messages)。这一步值得照做:你要在这份列表里同时看到模型返回的 function_call 项和你追加的 function_call_output 项,且后者的 call_id 与前者一致、顺序在前者之后。这三点任一不满足,第二次调用拿到的就不会是你期望的那段自然语言总结。

这一课的价值不在 search_courses 这个例子本身,而在于把「一次工具调用」拆成了可以逐段打印、逐段验证的三个断点:schema 出去、function_call 回来、function_call_output 再出去。换成任何一个别的工具,检查点还是这三个。


本文依据 github.com/microsoft/generative-ai-for-beginners 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码, 因此不涉及运行结果、耗时与生成质量的任何描述。 该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。 文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。