教程2026-10-21 · 智涌AI

OpenAI Agents SDK 接本地模型:4 个功能 3 个坑实测

OpenAI Agents SDK 教程封面:终端里 handoff 代码与「记账助手 十月交通费 47 元」输出,标注 Qwen3.5-4B Ollama 与关思考后 28.5 秒降到 7.8 秒

Agent 框架的教程大多默认你有 OpenAI API Key。这篇反过来:一块显卡都没有的 8 核云主机,装个 Ollama,跑 4B 小模型,把 OpenAI Agents SDK 的核心功能全走一遍,所有报错和翻车都照实写。

这套组合适合谁

想搞懂「多 Agent 协作」到底怎么回事、又不想先充值的开发者。Agents SDK 的设计很克制:Agent 就是「指令 + 模型 + 工具」,交接本质上是一个特殊工具,护栏是一个先跑的检查函数,记忆是一张 SQLite 表。弄懂这四样,换成云端大模型时代码几乎不用改。

实测环境(2026-10-21):Python 3.13.5、openai-agents 0.24.0、openai 3.35.1、Ollama 0.40.6、unsloth/Qwen3.5-4B-GGUF 的 Q4_K_M 量化;8 核 CPU、15GB 内存,无 GPU。

第 1 步:装依赖,起本地模型

curl -fsSL https://ollama.com/install.sh | sh
ollama serve &
hf download unsloth/Qwen3.5-4B-GGUF Qwen3.5-4B-Q4_K_M.gguf --local-dir gguf
# Modelfile 第一行:FROM ./gguf/Qwen3.5-4B-Q4_K_M.gguf
ollama create qwen35-4b -f Modelfile

uv venv .venv && . .venv/bin/activate
uv pip install openai-agents==0.24.0

Ollama 自带 OpenAI 兼容接口 http://127.0.0.1:11434/v1。关键是用 OpenAIChatCompletionsModel 走 Chat Completions,而不是 SDK 默认的 Responses 接口;再用 set_tracing_disabled(True) 关掉往 OpenAI 平台上传 trace(文档说明默认开启)。

第 2 步:一个会调工具的记账 Agent

准备一个 expenses.csv(日期、类别、金额 6 行),写一个 @function_tool 函数按类别求和。SDK 会把函数签名和 docstring 自动转成工具描述。第一次问「十月订阅花了多少」,返回 118 元,正确,但用了 50.1 秒(含模型冷启动)。

慢在思考。Qwen3.5 默认先输出推理过程,我对比了三种关法,同一问题各跑一次:

设置耗时有没有关掉思考
默认28.5 秒没有
extra_body={"think": False}38.8 秒没有,参数被忽略
reasoning_effort="none"7.8 秒关掉了

代码里写成 ModelSettings(reasoning=Reasoning(effort="none")) 即可,SDK 会转成 Chat Completions 的 reasoning_effort。

第 3 步:前台分流 + 两个专员

「前台」只做分流,账单问题交给记账助手,写文案交给文案助手。SDK 会给每个下游 Agent 生成一个 transfer_to_<名字> 工具,前台调用它就完成交接。

坑 1:Agent 名用中文,交接直接串线。运行时 SDK 打出警告:两个工具名都被改成 transfer_to_____,还提示名字冲突。结果「十月交通花了多少」被交给了文案助手,回了一句「请提供具体月份、地区」。解决办法是给交接指定英文工具名:handoff(bookkeeper, tool_name_override="to_bookkeeper"),Agent 显示名可以继续用中文。

小模型做 Agent,最不可靠的环节是「拿到工具结果后再复述一遍」。能让代码直接给答案的地方,就别让模型开口。

坑 2:交接之后,4B 模型会跳过工具自己编数。同一问题经前台交接跑 6 次,只有 3 次是对的 47 元,另外 3 次答成 2000 元、1735.60 元、120 元,而且根本没调工具;直接问记账助手则 5/5 正确。我试了几种修法:

修法(各跑 6 次)正确每次耗时
不处理3/66.0 秒
交接时用 remove_all_tools 过滤历史5/66.6 秒
记账助手打开思考6/622.7 秒
tool_use_behavior="stop_on_first_tool"6/65.7 秒

最后一种让工具返回值直接作为最终答案,模型没机会改数字,又快又准。代价是回答变成工具的原样输出(「交通 合计 47 元」),想要口语化就在工具里把句子拼好。我还试了 tool_choice="required",单独加没有改善,最终配置没用它。

第 4 步:护栏和记忆

输入护栏用正则拦 sk- 开头的密钥和手机号,命中就抛 InputGuardrailTripwireTriggered,不花一次模型调用。记忆用 SQLiteSession("user-42", "memory.db"),第三问「那订阅呢?」能接上前文。

坑 3:被护栏拦下的那句话,照样写进了记忆库。我打开 memory.db,最后一条就是带密钥的原话;把护栏改成 run_in_parallel=False 也一样。下一轮对话时这条会被带进上下文。解决办法是在捕获异常后调 await session.pop_item() 删掉它,实测删除后这条记录就没了。

还有一个随机出现的问题:前台偶尔会照着历史记录直接调用 sum_by_category,可它自己没有这个工具,SDK 直接抛 ModelBehaviorError 中断。给 Runner.run 传 RunConfig(tool_not_found_behavior="return_error_to_model") 后,错误会回给模型让它重试,不再崩。前台在「写完文案再追问订阅」这种场景下也容易分错,关思考时连续 3 次都没交给记账助手;只给前台打开思考后,这句追问在 5 次完整运行里都分对了,所以最终代码里只有前台保留思考。

完整代码

import asyncio, csv, re
from openai import AsyncOpenAI
from openai.types.shared import Reasoning
from agents.extensions import handoff_filters
from agents import (Agent, Runner, ModelSettings, OpenAIChatCompletionsModel, SQLiteSession,
                    function_tool, handoff, input_guardrail, GuardrailFunctionOutput,
                    InputGuardrailTripwireTriggered, RunConfig, set_tracing_disabled)

set_tracing_disabled(True)
client = AsyncOpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama")
model = OpenAIChatCompletionsModel(model="qwen35-4b", openai_client=client)
fast = ModelSettings(reasoning=Reasoning(effort="none"))  # 关掉思考

@function_tool
def sum_by_category(category: str) -> str:
    """统计 expenses.csv 里某个类别的总支出(元)。category 如 餐饮、交通、订阅。"""
    with open("expenses.csv", encoding="utf-8") as f:
        total = sum(float(r["amount"]) for r in csv.DictReader(f) if r["category"] == category)
    return f"{category} 合计 {total:.0f} 元"

@input_guardrail
async def block_secrets(ctx, agent, user_input):
    hit = re.search(r"sk-[A-Za-z0-9]{16,}|\b1[3-9]\d{9}\b", str(user_input))
    return GuardrailFunctionOutput(output_info=None, tripwire_triggered=bool(hit))

bookkeeper = Agent(name="记账助手", handoff_description="回答支出、花费、账单问题",
    instructions="涉及金额必须调用 sum_by_category,不要心算。中文一句话回答。",
    model=model, model_settings=fast, tools=[sum_by_category],
    tool_use_behavior="stop_on_first_tool")  # 工具结果直接当答案,不让模型复述
writer = Agent(name="文案助手", handoff_description="写短文案、标题、祝福语",
    instructions="你是文案助手,中文,30 字以内。", model=model, model_settings=fast)
triage = Agent(name="前台",
    instructions="你只负责分流:账单问题交给记账助手,写东西交给文案助手。自己不回答。",
    model=model, input_guardrails=[block_secrets],
    handoffs=[handoff(bookkeeper, tool_name_override="to_bookkeeper",
                      input_filter=handoff_filters.remove_all_tools),
              handoff(writer, tool_name_override="to_writer")])

async def ask(q, session):
    try:
        r = await Runner.run(triage, q, session=session, max_turns=6,
                             run_config=RunConfig(tool_not_found_behavior="return_error_to_model"))
        print(f"[{r.last_agent.name}] {r.final_output}")
    except InputGuardrailTripwireTriggered:
        await session.pop_item()  # 把被拦下的那句从记忆里删掉
        print("[护栏] 检测到密钥或手机号,已拒绝且未写入记忆")

async def main():
    s = SQLiteSession("user-42", "memory.db")
    for q in ["十月交通花了多少?", "帮我给这个月的省钱成果写一句朋友圈文案",
              "那订阅呢?", "我的 key 是 sk-abcdefghijklmnopqrstuvwx,帮我记一下"]:
        print("Q:", q); await ask(q, s)
    print("记忆条数:", len(await s.get_items()))

asyncio.run(main())

最终版连跑 2 次,4 个问题全对:交通 47 元、订阅 118 元、文案正常交给文案助手、密钥被拦且没进记忆。整轮 34–58 秒,大部分时间花在前台的思考上。文案助手偶尔会把 47 元写成「省出 47 元」,4B 模型的文字功夫就到这儿。

常见坑速查

完成标准:运行 team.py,四个问题分别由记账助手、文案助手、记账助手、护栏处理,最后打印的记忆条数里不含密钥那句。

未实测:接 OpenAI 云端模型、SDK 的语音与 MCP 集成、多 Agent 并行,以及 trace 面板,本文都没有测。

最后

把 expenses.csv 换成你自己的账单导出,照着这份代码跑一遍,先看你机器上的小模型能不能稳住这 4 个问题,再决定要不要上更大的模型。

📬 订阅《智涌AI周报》

每周一封,精选 AI 大事和实用技巧。