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

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_ 关掉往 OpenAI 平台上传 trace(文档说明默认开启)。
第 2 步:一个会调工具的记账 Agent
准备一个 expenses.csv(日期、类别、金额 6 行),写一个 @function_tool 函数按类别求和。SDK 会把函数签名和 docstring 自动转成工具描述。第一次问「十月订阅花了多少」,返回 118 元,正确,但用了 50.1 秒(含模型冷启动)。
慢在思考。Qwen3.5 默认先输出推理过程,我对比了三种关法,同一问题各跑一次:
| 设置 | 耗时 | 有没有关掉思考 |
|---|---|---|
| 默认 | 28.5 秒 | 没有 |
extra_ | 38.8 秒 | 没有,参数被忽略 |
reasoning_effort="none" | 7.8 秒 | 关掉了 |
代码里写成 ModelSettings( 即可,SDK 会转成 Chat Completions 的 reasoning_effort。
第 3 步:前台分流 + 两个专员
「前台」只做分流,账单问题交给记账助手,写文案交给文案助手。SDK 会给每个下游 Agent 生成一个 transfer_to_<名字> 工具,前台调用它就完成交接。
坑 1:Agent 名用中文,交接直接串线。运行时 SDK 打出警告:两个工具名都被改成 transfer_to_____,还提示名字冲突。结果「十月交通花了多少」被交给了文案助手,回了一句「请提供具体月份、地区」。解决办法是给交接指定英文工具名:handoff(,Agent 显示名可以继续用中文。
小模型做 Agent,最不可靠的环节是「拿到工具结果后再复述一遍」。能让代码直接给答案的地方,就别让模型开口。
坑 2:交接之后,4B 模型会跳过工具自己编数。同一问题经前台交接跑 6 次,只有 3 次是对的 47 元,另外 3 次答成 2000 元、1735.60 元、120 元,而且根本没调工具;直接问记账助手则 5/5 正确。我试了几种修法:
| 修法(各跑 6 次) | 正确 | 每次耗时 |
|---|---|---|
| 不处理 | 3/6 | 6.0 秒 |
交接时用 remove_all_tools 过滤历史 | 5/6 | 6.6 秒 |
| 记账助手打开思考 | 6/6 | 22.7 秒 |
tool_ | 6/6 | 5.7 秒 |
最后一种让工具返回值直接作为最终答案,模型没机会改数字,又快又准。代价是回答变成工具的原样输出(「交通 合计 47 元」),想要口语化就在工具里把句子拼好。我还试了 tool_choice="required",单独加没有改善,最终配置没用它。
第 4 步:护栏和记忆
输入护栏用正则拦 sk- 开头的密钥和手机号,命中就抛 InputGuardrailTripwireTriggered,不花一次模型调用。记忆用 SQLiteSession(,第三问「那订阅呢?」能接上前文。
坑 3:被护栏拦下的那句话,照样写进了记忆库。我打开 memory.db,最后一条就是带密钥的原话;把护栏改成 run_in_parallel=False 也一样。下一轮对话时这条会被带进上下文。解决办法是在捕获异常后调 await session.pop_item() 删掉它,实测删除后这条记录就没了。
还有一个随机出现的问题:前台偶尔会照着历史记录直接调用 sum_by_category,可它自己没有这个工具,SDK 直接抛 ModelBehaviorError 中断。给 Runner.run 传 RunConfig( 后,错误会回给模型让它重试,不再崩。前台在「写完文案再追问订阅」这种场景下也容易分错,关思考时连续 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 模型的文字功夫就到这儿。
常见坑速查
- 交接串线:中文 Agent 名会被替换成下划线,用
tool_name_override - 关思考:
think: False不生效,用reasoning_effort="none" - 编数字:算数类工具加
stop_on_first_tool - 记忆泄密:护栏触发后
session.pop_item() - 崩溃:小模型调用不存在的工具,设
tool_not_found_behavior
完成标准:运行 team.py,四个问题分别由记账助手、文案助手、记账助手、护栏处理,最后打印的记忆条数里不含密钥那句。
未实测:接 OpenAI 云端模型、SDK 的语音与 MCP 集成、多 Agent 并行,以及 trace 面板,本文都没有测。
最后
把 expenses.csv 换成你自己的账单导出,照着这份代码跑一遍,先看你机器上的小模型能不能稳住这 4 个问题,再决定要不要上更大的模型。
- GitHub:openai/openai-agents-python
- PyPI:openai-agents 0.24.0
- Agents SDK 文档:Models(接入非 OpenAI 模型、Chat Completions)
- Agents SDK 文档:Handoffs(tool_name_override、input_filter)
- Agents SDK 文档:Guardrails
- Agents SDK 文档:Sessions(SQLiteSession、pop_item)
- Ollama 文档:OpenAI 兼容接口
- Hugging Face:unsloth/Qwen3.5-4B-GGUF
- 本文实测:2026-10-21,box 云主机(8 核 CPU、无 GPU),以上耗时与正确率均为本机结果