惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tailwind CSS Blog
人人都是产品经理
人人都是产品经理
博客园 - 叶小钗
大猫的无限游戏
大猫的无限游戏
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 【当耐特】
The Cloudflare Blog
博客园 - 聂微东
博客园 - 司徒正美
量子位
博客园 - 三生石上(FineUI控件)
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
G
Google Developers Blog
Apple Machine Learning Research
Apple Machine Learning Research
罗磊的独立博客
酷 壳 – CoolShell
酷 壳 – CoolShell
Y
Y Combinator Blog
S
SegmentFault 最新的问题
T
The Blog of Author Tim Ferriss
P
Proofpoint News Feed
Google DeepMind News
Google DeepMind News
Blog — PlanetScale
Blog — PlanetScale
有赞技术团队
有赞技术团队
A
About on SuperTechFans

博客园 - 一名程序媛呀

Anki插件开发必知必会:钩子函数与右键菜单定制 httpx 传参总报错?这次把 GET、POST、文件上传到响应处理的坑给你一次填平 从卡顿到丝滑:FastAPI 调用外部 API 的正确姿势(httpx 实战) 还在 XHR、Fetch 和 Axios 之间纠结?我踩过的坑,希望你一个都不用碰到 你的REST接口还在“过度投喂”数据吗?——FastAPI + GraphQL实战避坑指南 Uvicorn、Gunicorn 傻傻分不清?FastAPI 生产部署避坑指南 Termux里的二进制和脚本,到底怎么运行才不踩坑?Termux-service 保活妙招! 刚部署的 LibreTranslate 频频翻车?我掏出了 20 年前的 StarDict 词典,用 FastAPI 搭了个本地词典翻译 API 别再用网页翻译看源码了!你的私人翻译神器LibreTranslate,部署避坑指南来了 掏出手机就能搭个 WebDAV 同步服务器?这操作有点香 别只盯着GitBook了!这个文档神器让你的笔记秒变网站 写爬虫时用了代理还被封?Python 代理的那些隐藏坑,我替你踩明白了 FastAPI 身份验证总踩坑?这份 FastAPI Users “避坑指南”请收好 旧手机别扔!用 Termux 搭个私人云盘,比网盘香多了 你的FastAPI又在服务器上“跑不起来”了?来,今天咱把打包这件事彻底聊透 写页面时别再把 Element Plus 整个搬进来啦!Vue3按需加载的坑我帮你踩平了 前端包管理咋选?我从npm叛逃到pnpm的血泪史(附避坑指南) 聊聊 fetch 使用中我踩过的那些坑和正确打开方式 FastApiAdmin 后端接口开发好了,前端管理界面怎么调用与显示? 给 FastApiAdmin 加个“会议纪要”模块,我把后端二次开发的坑踩了个遍 我用了FastApiAdmin后,连夜把踩过的坑都整理出来了 告别 Typora 后的新欢:我把所有笔记迁移到了 Obsidian 这个“第二大脑” 你的Agent API还在裸奔?从认证到沙箱,我用FastAPI搭了几道防线 让 FastAPI Agent 思考不阻塞:手把手教你实现异步任务与后台处理方案 让FastAPI Agent真正记住你:聊聊会话记忆与持久化存储的落地实践 初探:用 FastAPI 搭建你的第一个 AI Agent 接口 FastAPI 少有人提的实用技巧:把 Depends 依赖提到路由层,代码少写60% FastAPI 生产环境静态文件完全指南:从 /favicon.ico 404 到 HSTS 混合内容,一次全根治 用了loguru我才明白,Python日志还能这么写 FastAPI 后台任务:BackgroundTasks 的使用场景与注意事项
FastAPI Agent 函数调用实战:我让 AI 学会了“自己动手查天气“
一名程序媛呀 · 2026-05-10 · via 博客园 - 一名程序媛呀

接上回,我们已经可以通过API接口与Ollama大模型进行对话了,但它的回答基于模型内的训练数据,怎么让他获取实时数据呢?今天来解决这个问题:

你有没有遇到过这种尴尬:辛辛苦苦搭好的 AI 接口,问它“厦门今天多少度”,它一本正经地给你编了个 26°C,还附赠一段虚构的湿度描述。你说它错吧,语气还挺像那么回事;你说它对,它是真的在胡说。

这就是典型的“空脑子”问题——大模型如果没有手,就只能靠记忆瞎掰。今天咱们就给 Agent 装上双手,让它能自己查天气、算数字、搜资料。

全程用 FastAPI + Pydantic + 函数调用,不套重型框架,手动实现一个清晰可控的工具执行模式。

🎯 本文能帮你解决的

把只能“纯聊天”的 Agent 升级成 能调用真实函数干活的智能助手,并且建立一套结构化的工具定义、选择、执行和回退机制。

你会得到一个可扩展的工具链骨架,往后加新功能就像插积木一样顺手。

🧭 核心脉络

🚩 案例引子:一个瞎编天气的 Agent 有多坑

🚩 工具定义的艺术:用 Pydantic 给函数写“说明书”

🚩 意图解析与工具选择:LLM 吐出调用指令后怎么做

🚩 异步执行器 + 错误降级:超时、参数错、网络断都不慌

🚩 实战端点改造:从 /chat/agent,只多几十行代码

🍳 第一部分:空脑子 Agent 的翻车现场

上篇我们把 Ollama 接进了 FastAPI,一个 /chat 端点走天下。

但如果“你问他厦门明天什么天气,他回得有鼻子有眼,但一查根本没那回事。”

这太正常了,因为 模型没有调用外部工具的能力,就像把厨师锁在没有食材的厨房里,只能给你画菜。

解决办法就是 函数调用(Function Calling)
告诉模型:嘿,你没这本事就别硬编了,遇到这类问题,直接告诉我要调哪个工具,我帮你去跑。

🔧 第二部分:核心原理——把函数变成模型能看懂的菜单

整个思路特别像给大厨配一本标准化菜谱:

📋 菜谱(工具定义) → 用 Pydantic 模型描述函数名、作用、参数类型

🧠 大厨看单(意图解析) → LLM 读到用户消息,返回想调用的函数名和参数

👩‍🍳 真正炒菜(执行器) → 我们的代码接收指令,去调用真实的天气 API 或计算函数

🍽️ 上菜(回传结果) → 把工具返回的数据再喂给模型,生成最终的自然语言回答

好,咱们先来定义工具。在 FastAPI 里,我习惯用一个 Pydantic BaseModel 来描述每个工具的输入参数。比如天气查询:

from pydantic import BaseModel

class WeatherParams(BaseModel):
    city: str
    date: str = "today"

然后把工具元信息统一注册到一个列表里。这里注意,尽量别把所有函数都塞进一个散装 dict,参数一多就乱套,调试到凌晨三点才发现是因为忘传了 required 字段。下面给出两种定义方式,作为对比参考。

另外 特别提醒:工具元信息注册格式一定要规范,否则大模型识别不到你的工具函数。
再就是 确保你拉取的模型支持工具函数调用,前面我用的 qwen3:0.6b 结果就是不调用工具,换成 qwen3.5:0.8b 立马识别到工具了,那个心酸呀!
后面多轮对话时,又出现第二轮和第一轮回复相同内容的情况,一通折腾,最后找到原因是小模型做不了轮对话,至少要是 7b 以上!!!吃了电脑配置不够的亏!!!😭

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市天气",
            "parameters": WeatherParams.model_json_schema()
        }
    },
    {
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "执行数学计算",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {"type": "string", "description": "数学表达式"}
                },
                "required": ["expression"]
            }
        }
    }
]

⚡ 第三部分:实战——让 /agent 端点“长出双手”

接下来咱们改造之前的 /chat,变成 /agent

核心变化是:把用户的请求先发给大模型,但这次要多传一个 tools 参数,告诉它你有这些家伙可以用。
模型会返回一个类似 {"name":"get_weather","arguments":{"city":"杭州"}} 的指令。

我在 main.py 里新增了一个异步函数专门处理这件事:

async def execute_tool(tool_name: str, arguments: dict):
    if tool_name == "get_weather":
        # 这里接你们实际的天气 API,我暂时用模拟数据
        city = arguments.get("city", "未知")
        return f"{city}今天晴朗,26.8°C,适合写代码"
    elif tool_name == "calculate":
        expr = arguments.get("expression", "")
        try:
            result = eval(expr)  # 仅演示,生产务必沙箱
            return str(result)
        except Exception:
            return "计算出错,请检查表达式"
    else:
        return "这个工具我还没学会"

你可能会问:“怎么把工具执行结果再送回模型?”
这里有一个我踩了无数坑的细节:一定要在第二轮请求里把之前的消息和工具结果都拼进去,组成完整的对话历史,不然模型会失忆,以为你在自言自语。

端点的写法大致是这样:

@router.post("/agent")
async def agent_endpoint(req: ChatRequest, config: Settings = Depends(get_settings)):
    # 第一轮:发给 LLM,带上 tools
    first_resp = await call_llm(req.message, tools=TOOLS, config=config)
    logger.debug(f"第一轮回复:\n{first_resp }")

    # 如果没有 tool_calls,直接返回内容
    tool_call = first_resp.get("tool_calls", [None])[0]
    if not tool_call:
        return ChatResponse(reply=first_resp["content"])

    # ---- 第二轮:执行工具并拼接消息 ----
    # 1. 把第一轮的 assistant 消息加入历史
    messages = [
        {"role": "system", "content": "你是一个能调用工具的助手,必要时使用工具,否则直接回答。当有工具返回结果时,请直接基于结果回答用户,不要再次调用工具,也不要忽略结果"},
        {"role": "user", "content": req.message},
        first_resp   # assistant 消息,包含 tool_calls
    ]

    # 2. 执行每个工具调用,并将结果作为 tool 消息追加
    for tc in first_resp["tool_calls"]:
        tool_name = tc["function"]["name"]

        # arguments 返回的可能是字串,也可能已经解析为对象了,作个判断
        raw_args = tc["function"]["arguments"]
        if isinstance(raw_args, str):
            arguments = json.loads(raw_args)
        else:
            arguments = raw_args  # 已经是 dict 了,直接用

        result = await execute_tool(tool_name, arguments)   # 你的工具执行器

        messages.append({
            "role": "tool",
            "tool_call_id": tc["id"],   # 必须和上面对齐
            "content": result           # 工具返回的字符串
        })

    # 3. 把完整历史再发给模型,让它总结成人话,不用带 tools 了
    final_resp = await call_llm(messages=messages, config=config)
    logger.debug(f"第二轮回复:\n{first_resp }")
    return ChatResponse(reply=final_resp["content"])

再说个容易翻车的点:工具执行那一步一定要加超时控制和 try-except

之前有次接的天气 API 抽风,整个 /agent 接口跟着一起卡死,前端直接白屏。
后来强制设了 httpx 的超时,并且加了一条黄金规则——工具调用失败时,绝不抛异常,而是把错误信息当作工具结果返回给模型,让它自己圆场。这样用户至少能收到一句“天气服务暂时不可用”,而不是一个冷冰冰的 500。

大模型访问 call_llm() 也要做出重构

核心思路: 用 httpx.AsyncClient 调 Ollama 的 /api/chat 端点(注意是 /api/chat,支持 tools 参数,不是之前的 /api/generate)。在请求体里带上 messages 和 tools,再处理返回的 message 字段。

async def call_llm(
    user_message: str = None,          # 方便快捷调用
    messages: list[dict] = None,       # 传完整历史
    tools: list[dict] = None,
    config: Settings = None,
    system_prompt: str = "你是一个能调用工具的助手,必要时使用工具,否则直接回答。"
):
    if messages is None:
        messages = [{"role": "system", "content": system_prompt}]
        if user_message:
            messages.append({"role": "user", "content": user_message})
    # 也可以选择动态添加 system prompt,但要避免重复,这里简化处理
    logger.debug(f"对话消息:\n{ messages }")

    payload = {
        "model": config.model_name,
        "messages": messages,
        "stream": False
    }
    if tools:
        payload["tools"] = tools

    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"{config.ollama_base_url}/api/chat",
            json=payload,
            timeout=30.0
        )
        resp.raise_for_status()
        data = resp.json()
        return data["message"]  # 包含 content 和可能的 tool_calls

⚠️ 第四部分:进阶与避坑指南

⭕ 工具定义尽量用 Pydantic 的 schema() 生成,别手写 JSON,那种参数名拼错的疼我挨过太多次。

⭕ 执行器里 eval 只用来演示,生产环境请用安全的表达式解析库,或者直接写死允许的操作。

⭕ 如果 LLM 不支持原生 tool_calls,也可以自己在 system prompt 里要求它返回特定格式的 JSON,然后手动解析,效果一样稳。

⭕ 后续可以把工具执行器改成插件式注册,用装饰器收集,加新工具就跟往工具箱里丢一把扳手一样简单。


今天的更新就到这儿。把一个只会聊天的模型,变成能动手干活的 Agent,那种感觉就像教孩子学会了骑自行车。

如果你也正在这条路上摸索,赶紧把代码拿去跑一跑,遇到坑了别怕,评论区甩过来,咱俩一起填。

觉得有收获的话,点赞、收藏加关注,别让这些实战经验被算法埋没了。下篇我们继续往 Agent 里塞更多东西,不见不散🚀。