通过 dspy.LM、openai/ 前缀和 一个 api_base 把 DSPy 接到 Router One
DSPy(stanfordnlp/dspy)是一个开源 Python 框架,主张对语言模型「编程而不是写提示词」:先声明 question -> answer 这样的签名,再把它包进模块(dspy.Predict、dspy.ChainOfThought、dspy.ReAct),需要时让优化器按指标改写提示词。所有模型调用都经过同一个类 dspy.LM,它接收 LiteLLM 风格的 provider/model 字符串,以及 api_base 和 api_key。加上 openai/ 前缀并填入 Router One 的 /v1 base URL 后,每次调用就是一个 POST /v1/chat/completions,一把 Key 就能调用目录里的所有聊天模型,每次请求在控制台 → 日志都有成本和延迟 Trace;签名、adapter、工具、指标和优化器仍在你的进程里运行。本指南覆盖安装、最容易让第一次运行失败的模型字符串规则、DSPy 的哪个设置发出哪种请求、为什么 DSPy 的响应缓存会让日志里的行数少于代码里的调用次数,以及怎样用专用 Key 给一次优化器运行封顶。客户端行为按 DSPy 3.3.1 核对;DSPy 3.4.0 已于 2026 年 9 月 25 日发布,现为 PyPI 上的当前正式版,下文注明其源码与 3.3.1 的不同之处。
安装 dspy 并设置 凭证
使用 Python 3.10 或更新版本:PyPI 上的包元数据要求 >=3.10 且 <3.15,DSPy 的安装页也写明它适用于 Python 3.10+ 环境。官方给出的安装方式是 pip install dspy(或 uv add dspy);litellm 和 openai 包都是它声明的依赖,接 OpenAI 兼容端点不需要再装别的。pip 会装最新的正式版,截至 2026-09-29 是 3.4.0(2026 年 9 月 25 日发布),而本指南的客户端行为按 3.3.1 核对;pip install dspy==3.3.1 可以完全复现。DSPy 把 3.4 称为 LM 过渡版本:默认的 engine='auto' 对它支持的路径优先使用原生客户端,其余路径在请求发出前改用 LiteLLM。按 3.4.0 的源码,openai/ 前缀加 api_base 仍然产生一次 Chat Completions 请求,model 字段是前缀之后的全部内容,所以下面的示例不变;本页的异常类名、重试次数等随版本变化的细节是在 3.3.1 上观察到的。下面是 macOS/Linux 的 shell 示例,运行前替换两个占位符:一把 Router One Key,以及当前目录中某个聊天模型的精确 ID。ROUTER_ONE_* 是本示例自己定义并显式读取的变量名;Key 直接作为 api_key 传给 dspy.LM,所以不需要设置 OPENAI_API_KEY,而且即使环境里设了 OPENAI_API_KEY 或 OPENAI_API_BASE,生效的仍是 api_key 和 api_base 这两个参数。运行 Python 文件时沿用同一环境。
python -m pip install -U dspy export ROUTER_ONE_API_KEY="sk-your-router-one-key" export ROUTER_ONE_MODEL_ID="<exact-model-id-from-/models>"
把 DSPy 配置到 Router One base URL
保存为 program.py,再运行 python program.py。dspy.LM 接收 LiteLLM 风格的 provider/model 字符串,第一段必须是 openai/:LiteLLM 文档把这个前缀定义为「调用 OpenAI /chat/completions 端点」的指令,而字符串只在第一个斜杠处切分一次,openai/ 之后的内容会原样作为请求里的 model 字段发出。示例用 ROUTER_ONE_MODEL_ID 拼出它,所以 openai/gpt-5.5 这样的 GPT 系列 ID 写成 openai/openai/gpt-5.5,Claude 的 ID 写成 openai/anthropic/claude-sonnet-5。api_base 填带 /v1 的 base URL,后面不加任何路径;底层的 OpenAI 客户端会自己补上 /chat/completions。api_key 填 Router One Key。model_type='chat' 本来就是默认值,写出来只是为了把端点说清楚。cache=False 是为头几次运行准备的:DSPy 默认把响应缓存在内存和硬盘里,不关掉的话,同一个文件第二次运行会直接由本地回答,不发出任何请求。dspy.configure(lm=lm, track_usage=True) 把这个 LM 设为所有模块的默认模型,并打开 token 统计,result.get_lm_usage() 会按模型字符串返回统计结果。dspy.Predict 为签名 question -> answer 发出一次请求;dspy.ChainOfThought 多一个 reasoning 输出字段,仍然是一次请求;dspy.ReAct 每轮迭代发一次请求,最后再发一次提取最终答案,而 add 函数在你的进程里执行。在 3.3.1 里 temperature 和 max_tokens 默认为 None,不会写进请求,所以生效的是模型自己的默认值。dspy.inspect_history(n=1) 打印 DSPy 实际发出的最后一次提示词和回复,lm.history 为每次调用保留一条带 usage 的记录:
import os
import dspy
# openai/ selects the OpenAI Chat Completions route; the rest of the string is sent
# unchanged as the model field (openai/openai/gpt-5.5 for a GPT-family ID).
lm = dspy.LM(
"openai/" + os.environ["ROUTER_ONE_MODEL_ID"],
api_base="https://api.router.one/v1", # ends in /v1, nothing after it
api_key=os.environ["ROUTER_ONE_API_KEY"],
model_type="chat", # POST /v1/chat/completions
cache=False, # first runs: no DSPy cache, so every call goes to the gateway
)
dspy.configure(lm=lm, track_usage=True)
# 1. One Predict call is one request
qa = dspy.Predict("question -> answer")
result = qa(question="What is the capital of France?")
print(result.answer, result.get_lm_usage())
# 2. ChainOfThought adds a reasoning output field; still one request
cot = dspy.ChainOfThought("question -> answer")
print(cot(question="What is 17 * 23?").answer)
# 3. ReAct: one request per iteration plus one that extracts the answer; the tool runs in your process
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
agent = dspy.ReAct("question -> answer", tools=[add], max_iters=5)
print(agent(question="What is 1234 + 4321?").answer)
# The last call as DSPy sent it, and the per-call records DSPy keeps
dspy.inspect_history(n=1)
print(len(lm.history), lm.history[-1]["usage"])DSPy 的哪个 设置 发出哪种 请求
连接信息由 dspy.LM 携带,每个请求长什么样则由模块和 adapter 决定。下表按本指南涉及的设置,给出该填的值、它产生的请求,以及使用前要确认的事项。
| DSPy 设置 | 填什么 | 怎么验证 |
|---|---|---|
| dspy.LM(model=…) | openai/ 加上精确的目录 ID:openai/anthropic/claude-sonnet-5、openai/deepseek-v4.1-flash,GPT 系列 ID 写成 openai/openai/gpt-5.5 | 日志里每条 Trace 的模型就是去掉开头 openai/ 的 ID;只有第一个斜杠是分隔符,属于 ID 本身的前缀会保留 |
| dspy.LM(api_base=…) | https://api.router.one/v1 | 以 /v1 结尾,后面什么都不加;少了 /v1,请求会打到 /chat/completions,网关返回 404 not_found,DSPy 3.3.1 会把它抛成 dspy.LMUnsupportedModelError,尽管模型 ID 并没有问题 |
| dspy.LM(api_key=…) | 为这个程序单独创建、设了 maxSpend 的 Router One Key,从 ROUTER_ONE_API_KEY 读取 | 第一条 Trace 出现在控制台 → 日志里这把 Key 名下;即使设了 OPENAI_API_KEY,生效的也是这个参数;Key 无效时表现为状态码 401 的 dspy.LMAuthError |
| model_type(默认 chat) | chat 发送 POST /v1/chat/completions,目录里的所有聊天模型都在这个端点上提供;responses 发送 POST /v1/responses | responses 只用于当前在售的 GPT 系列、DeepSeek ID 与 Grok 对话模型,也就是该端点提供服务的系列;Claude 系列 ID 会收到 400,消息里写明应调用的端点(/v1/messages 或 /v1/chat/completions),Gemini 的 ID 在这里同样不提供 |
| temperature、max_tokens(默认 None) | 不设置就不会写进请求 | 对 DSPy 认定为 OpenAI 推理模型的名字,它会拒绝 1.0 以外的非零 temperature 和小于 16000 的 max_tokens,并用 max_completion_tokens 代替 max_tokens 发送;哪些 ID 会匹配见下面的 FAQ |
| cache(默认 True) | 完全相同的请求由 DSPy 的内存或硬盘缓存(~/.dspy_cache,或 DSPY_CACHEDIR 指定的目录)回答,不发出任何请求 | 这次调用在日志里没有记录,get_lm_usage() 也为空;传 cache=False,或者换一个 rollout_id 并配上非零 temperature,才会强制发出请求 |
| dspy.Predict、dspy.ChainOfThought、dspy.ReAct | Predict 和 ChainOfThought 每次调用发一次请求;ReAct 每轮迭代发一次,最多 max_iters 轮(3.3.1 的函数签名里是 20),最后再发一次提取答案 | 请求里没有 tools 字段:ReAct 在提示词里描述工具,再从文本里解析 next_tool_name 和 next_tool_args,所以不要求工具调用能力,函数在你的进程里执行 |
| adapter(默认 ChatAdapter) | ChatAdapter 发送带 [[ ## field ## ]] 标记的普通消息,回复解析失败时会改用 JSONAdapter 再调用一次;JSONAdapter 会加上 response_format | JSONAdapter 用 json_schema 还是 json_object,取决于 LiteLLM 本地的模型元数据,而不是网关;先在模型详情页确认结构化输出能力;response_format 被拒绝时会抛出 dspy.LMInvalidRequestError,不会悄悄退回 JSON 模式重试 |
给一次 优化器 运行定 预算,并与 日志对账
DSPy 程序在推理时只是少量请求,花费集中在 compile()。优化器会在训练集和验证集上把你的程序反复运行很多遍:DSPy 的 FAQ 给出过一次 BootstrapFewShotWithRandomSearch 编译的数字,大约 3,200 次 API 调用、270 万输入 token 和 15.6 万输出 token,优化器选择指南也提醒 MIPROv2 和 GEPA 的 compile() 开销很大。预算开关都在客户端,数的是工作量而不是钱:MIPROv2 的 auto(默认 light,其次是 medium 和 heavy)对应 6、12 或 18 个候选,并把验证集上限定为 100、300 或 1,000 条;GEPA 要求在 auto、max_full_evals、max_metric_calls 三者中恰好设置一个,并在开始前把预计的指标调用次数写进日志。GEPA 的 reflection_lm 和 MIPROv2 的 prompt_model 是独立的 dspy.LM 对象,给它们同样的 openai/ 前缀和 api_base;想把生成提示词的花费和任务本身的花费分开,就再给它们单独一把 Key。并发来自 num_threads:dspy.Evaluate 和各优化器没有显式设置时会用 dspy.settings.num_threads,默认值是 8,所以一次评测中最多同时有 8 个请求在途。如果 Key 的 rateLimit 低于这个突发量,网关会返回 429,DSPy 在重试之后抛出 dspy.LMRateLimitError;这时应该调低 num_threads,或发邮件到 support@router.one 申请调高 Key 的 rateLimit,而不是增加重试。重试会放大请求数:num_retries 默认是 3,在本地 mock 服务器上,DSPy 3.3.1 搭配 LiteLLM 1.101 对返回 429 或 500 的请求一共发送了 7 次,对返回 400、401、402 或 404 的请求一共发送了 4 次,然后才抛出异常;num_retries=0 时只发送一次。客户端每一次经网关发往模型的尝试在控制台 → 日志里都是独立请求,有各自的 request_id。给每个程序、以及每一次你想封顶的编译运行,单独建一把设了 maxSpend 的 Key:触到上限后,下一次调用返回 HTTP 402,DSPy 抛出 dspy.LMBillingError,并行执行器在失败样本数达到 max_errors(默认 10)后停止,消息为 Execution cancelled due to errors or interruption;花费不会超过这个上限,你的其他 Key 也照常可用。只要 cache=True,日志里的行数就会少于代码里的调用次数:完全相同的请求,包括整次重复的编译,都会从硬盘重放;而且缓存键不包含 api_key 和 api_base,同一个模型字符串在别的端点上缓存过的回答也会被重放。get_lm_usage() 是 DSPy 对 API 报告用量的统计,缓存命中的调用为空;lm.history 里的 cost 字段是 LiteLLM 按它自己的价格表估算的,遇到它不认识的 ID 可能是 None。费用以日志记录为准:按 Key、时间、精确模型和 token 数对账,报障时保留 request_id。Router One 只服务模型请求并记录 Trace。优化器、指标、工具和缓存都在你的进程里运行。
Embedding 和微调留在 网关 之外
dspy.Embedder 既接受托管模型的字符串,也接受一个 Python 可调用对象。openai/text-embedding-3-small 这样的托管字符串会走 LiteLLM 的 embedding 调用,配上 Router One 的 api_base 就是 POST /v1/embeddings,而网关不提供这个端点,所以调用会以 404 失败,LiteLLM 抛出的是 NotFoundError。Embedding 要走另一条路:Embedder 的参考文档给出了把本地 sentence-transformers 模型作为可调用对象传入的写法 dspy.Embedder(model.encode);其他服务商的托管 embedding 模型,则用那家自己的 Key 和 api_base。dspy.retrievers.Embeddings 把 embedder 作为参数接收,所以 RAG 程序可以用本地或第三方 embedder 做检索,只把生成调用通过 dspy.LM 发到 Router One。这两个对象互不相干:dspy.configure(lm=...) 不会影响 embedder,embedder 的请求也不会出现在日志里。训练也是同样的边界:BootstrapFinetune 这类训练权重的优化器需要微调 API,Router One 不提供;而提示词优化器(BootstrapFewShot、MIPROv2、GEPA)只需要聊天调用。
DSPy 该填 哪个 模型 ID?
从 /models 页复制精确的模型 ID,保留大小写、连字符和版本后缀,不要用展示名称代替。打开该模型的详情页,核对支持的 API 端点、上下文窗口和工具调用等能力,再与 DSPy 当前选择的 provider 和功能对应。模型出现在目录里,不等于当前客户端能调用它的所有功能。建议为每个客户端或应用单独建 API Key,并设置 maxSpend 消费上限。
DSPy 用的是哪种 API 协议?
OpenAI 兼容描述的是接口格式,不能据此推断 Chat Completions(/v1/chat/completions)、Responses(/v1/responses)和 Anthropic Messages(/v1/messages)可以互换。先核对工具当前版本、provider 配置和实际请求路径,再查模型详情与 API 兼容性事实页。一次普通对话成功,也不能证明服务端工具、历史状态或文件编辑功能都受支持。
在请求日志里 核对 DSPy 的调用
先在 DSPy 发出一次简单文本请求,再到控制台 → 日志按时间、模型和 request_id 找到这条记录,核对 Token、花费、总耗时和状态码。成功后再分别验证流式输出、工具调用和多轮历史。失败时保留实际请求路径、完整错误消息及 request_id;没有对应日志时,要么请求没到达网关(客户端配置或网络问题),要么网关在调用模型之前就拒绝了它——Key 问题的 401、余额不足或 Key 达到 maxSpend 上限的 402、Key 或账户自身限额触发的 429,或模型 ID、端点不对的 400 / 404;在输出任何内容之前就失败的流式请求也可能没有日志。看客户端收到的状态码和错误消息就能区分是哪一种;先确认这一点,再判断是不是网关或上游故障。
常见 问题
dspy.LM 报 LLM Provider NOT provided,或者 调用 根本 没有 出现在 日志里,模型 字符串错在 哪里?
模型字符串的第一段就是 provider 开关,只有 openai/ 会选中把请求发到 api_base 的 OpenAI Chat Completions 路径。直接传 deepseek-v4.1-flash 或 grok-4.6 这样不带前缀的目录 ID,什么都不会发出:DSPy 3.3.1 抛出 dspy.LMInvalidRequestError,里面包着 litellm.BadRequestError: LLM Provider NOT provided. Pass in the LLM provider you are trying to call. You passed model=deepseek-v4.1-flash。如果传入的 ID 第一段恰好是 LiteLLM 的某个 provider 名称,例如 anthropic/claude-sonnet-5,它会被交给那家 provider 自己的处理器:请求变成一次 Anthropic Messages 调用,model 被截成 claude-sonnet-5,于是没有任何 Chat Completions 请求到达网关;在本地测试中,api_base 填 /v1 地址时,LiteLLM 1.101 把它发到了重复的 /v1/v1/messages 路径,结果是 404。所有系列都把 openai/ 放在第一段:只有这一个前缀会被去掉,其余部分作为 model 字段发出,所以 GPT 系列 ID 写成 openai/openai/gpt-5.5。接着核对另外两个参数。api_base 必须以 /v1 结尾且不追加任何路径;LiteLLM 文档把 Not Found 错误归因于缺少 /v1 后缀,而 DSPy 3.3.1 会把这个 404 报成 dspy.LMUnsupportedModelError,名字指向模型,实际问题却在 base URL。401 会以 dspy.LMAuthError 出现,核对运行该文件的环境里 ROUTER_ONE_API_KEY 的值。这些异常都是 dspy.LMError 的子类,带有 HTTP 状态码;响应里有 request_id 时也会一并带上。
同一个 程序跑了 两遍,第二 遍在 日志里 一条 记录 都没有,请求丢了吗?
没有丢。DSPy 默认用两层缓存保存 LM 响应:内存里的 LRU 缓存,以及 ~/.dspy_cache 下的硬盘缓存(可用 DSPY_CACHEDIR 改位置),缓存教程写明两者无需任何操作就已启用。模型字符串、messages 和参数都与之前某次请求相同的调用会由本地回答:不发出任何请求,所以没有 Trace 也没有费用;开着 track_usage=True 时,这次调用的 usage 也是空的。开发阶段这很有用,重复的评测或编译第二次不花钱;对账时却容易让人困惑。想让每次调用都到达网关,给 dspy.LM 传 cache=False,或者用 dspy.configure_cache(enable_disk_cache=False, enable_memory_cache=False) 把两层都关掉。想保留缓存、只强制取一次新的采样,就传一个新的 rollout_id 并配上非零 temperature;dspy.LM 的参考文档写明 rollout_id 会在请求发出前被去掉。核对次数时,要拿日志和没有命中缓存的调用比,而不是和 len(lm.history) 比,后者把缓存命中的调用也记在内。
DSPy 报 OpenAI's reasoning models require passing temperature=1.0 or None and max_tokens >= 16000 or None,哪些 Router One 的 ID 会触发它?
这个检查发生在 dspy.LM 内部、任何请求发出之前,抛出的是 dspy.LMConfigurationError。DSPy 取模型字符串最后一个斜杠之后的部分,转成小写,再和一个 OpenAI 推理模型名称的正则比对,所以前面有几个 openai/ 都没有影响:openai/openai/gpt-5.5 是按 gpt-5.5 来检查的。在 DSPy 3.3.1 以及 3.4.0b1 预发布版里,这个正则覆盖 o1、o3、o4、o5 系列名称,以及 gpt-5 和 gpt-5- 开头的名称(gpt-5-chat 除外);gpt-5.5、gpt-5.6-sol 这类带点号的名字不匹配,所以这些 ID 在客户端侧可以用任意 temperature 和 max_tokens。按源码,2026 年 9 月 25 日发布的 DSPy 3.4.0 把正则放宽到所有带点号的 gpt-5 版本:在 3.4.0 上,openai/openai/gpt-5.5、openai/openai/gpt-5.6-sol 和 openai/openai/gpt-5.6-terra 都会匹配。名称匹配时,如果 temperature 设成了 1.0 以外的非零值,或者 max_tokens 设得小于 16000,dspy.LM 就会抛出上面的错误;同时它会用 max_completion_tokens 代替 max_tokens 发送。最省事的写法是两个参数都不设,示例就是这样做的;或者对匹配的 ID 传 temperature=1.0 和 max_tokens=16000。其他 ID,例如 anthropic/claude-sonnet-5、deepseek-v4.1-flash、grok-4.6,以及 GPT-6 的 openai/gpt-6-astra,在两个版本的正则下都不匹配。
DSPy 能通过 网关用 哪些 模型?
选用当前目录中同时支持 DSPy 所用端点和所需功能的模型。精确 ID、当前单价与能力以 /models 及模型详情为准;不要仅按 GPT、Claude 等系列名判断兼容性。客户端能列出模型,只说明它从自身配置或 GET /v1/models 读到了这个 ID,仍需验证实际调用。
能列出 模型,但调用报 400 或 404,怎么办?
先记录实际请求路径和错误消息,再核对精确模型 ID。400 可能是参数、工具类型或模型与端点不匹配;404 可能是请求路径或资源不存在,不能直接判定模型下线。若错误提示 must be called via,按它指明的端点调整客户端 provider,或换用支持当前端点的模型。不要在所有工具里统一增删 /v1 或 /chat/completions。
中国 大陆能 直连吗?
能。网关在大陆可直连、无需 VPN,配置与全球环境完全一致。
报 401/402/403/429 怎么 排查?
先看客户端收到的响应:状态码、错误消息和 X-Request-ID 响应头。401(Key 缺失、不存在、已吊销或已过期)、402(钱包余额不足,或这把 Key 达到 maxSpend 上限)以及 Key 或账户自身限额触发的 429,都在调用任何模型之前被拒绝,不会出现在控制台 → 日志里;模型 ID 或端点写错导致的 400 / 404 也一样。401 查 Key 是否完整传入、在控制台 → API 密钥里是否仍有效;402 充值或调高这把 Key 的 maxSpend;403 请保留完整错误消息。429 请退避后重试;日志里能查到的 429 是网关重试之后上游仍返回的。保留 request_id,再按错误码速查页逐项排查。