通过 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 就能调用目录里的所有聊天模型,每次请求在 Dashboard → Logs 都有成本和延迟 Trace;签名、adapter、工具、指标和优化器仍在你的进程里运行。本指南覆盖安装、最容易让第一次运行失败的模型字符串规则、DSPy 的哪个设置发出哪种请求、为什么 DSPy 的响应缓存会让 Logs 里的行数少于代码里的调用次数,以及怎样用专用 Key 给一次优化器运行封顶。客户端行为按 PyPI 上的当前正式版 DSPy 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 会装最新的正式版,本指南核对时是 3.3.1;3.4.0b1 是预发布版,不特别指定 pip 不会装。下面是 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: every call reaches the gateway and shows up in Logs
)
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 | Logs 里每条 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 出现在 Dashboard → Logs 里这把 Key 名下;即使设了 OPENAI_API_KEY,生效的也是这个参数;Key 无效时表现为状态码 401 的 dspy.LMAuthError |
| model_type(默认 chat) | chat 发送 POST /v1/chat/completions,目录里的所有聊天模型都在这个端点上提供;responses 发送 POST /v1/responses | responses 只用于当前在售的 GPT 系列与 DeepSeek ID,也就是该端点提供服务的系列;其他 ID 会收到 400,消息里写明应该调用哪个端点 |
| 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 指定的目录)回答,不发出任何请求 | 这次调用在 Logs 里没有记录,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 模式重试 |
给一次优化器运行定预算,并与 Logs 对账
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 或调高 Key 的 rateLimit,而不是增加重试。重试会放大请求数:num_retries 默认是 3,在本地 mock 服务器上,DSPy 3.3.1 搭配 LiteLLM 1.101 对返回 429 或 500 的请求一共发送了 7 次,对返回 400、401、402 或 404 的请求一共发送了 4 次,然后才抛出异常;num_retries=0 时只发送一次。每一次真正到达网关的尝试在 Dashboard → Logs 里都是独立请求,有各自的 request_id。给每个程序、以及每一次你想封顶的编译运行,单独建一把设了 maxSpend 的 Key:触到上限后,下一次调用返回 HTTP 402,DSPy 抛出 dspy.LMBillingError,并行执行器在失败样本数达到 max_errors(默认 10)后停止,消息为 Execution cancelled due to errors or interruption,钱包和其他 Key 不受影响。只要 cache=True,Logs 里的行数就会少于代码里的调用次数:完全相同的请求,包括整次重复的编译,都会从硬盘重放;而且缓存键不包含 api_key 和 api_base,同一个模型字符串在别的端点上缓存过的回答也会被重放。get_lm_usage() 是 DSPy 对 API 报告用量的统计,缓存命中的调用为空;lm.history 里的 cost 字段是 LiteLLM 按它自己的价格表估算的,遇到它不认识的 ID 可能是 None。费用以 Logs 记录为准:按 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 的请求也不会出现在 Logs 里。训练也是同样的边界: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 兼容性事实页。一次普通对话成功,也不能证明服务端工具、历史状态或文件编辑功能都受支持。
在 trace 里验证 DSPy 的调用
先在 DSPy 发出一次简单文本请求,再到 Dashboard → Logs 按时间、模型和 request_id 核对 trace:tokens、花费、延迟和状态码。成功后再分别验证流式输出、工具调用和多轮历史。失败时保留实际请求路径、完整错误消息及 request_id;没有对应日志时,先查客户端配置和网络,不能仅凭客户端报错认定是网关或上游故障。
常见问题
dspy.LM 报 LLM Provider NOT provided,或者调用根本没有出现在 Logs 里,模型字符串错在哪里?
模型字符串的第一段就是 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 时也会一并带上。
同一个程序跑了两遍,第二遍在 Logs 里一条记录都没有,请求丢了吗?
没有丢。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 会在请求发出前被去掉。核对次数时,要拿 Logs 和没有命中缓存的调用比,而不是和 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。main 分支在 2026-09-16 把正则放宽到带点号的 gpt-5 版本,所以以后升级之后,同样的 ID 就会匹配。名称匹配时,如果 temperature 设成了 1.0 以外的非零值,或者 max_tokens 设得小于 16000,dspy.LM 就会抛出上面的错误;同时它会用 max_completion_tokens 代替 max_tokens 发送。最省事的写法是两个参数都不设,示例就是这样做的;或者对 GPT 系列 ID 传 temperature=1.0 和 max_tokens=16000。其他系列的 ID,例如 anthropic/claude-sonnet-5、deepseek-v4.1-flash、grok-4.6 或 openai/gpt-6-astra,在两个版本的正则下都不匹配。
DSPy 能通过网关用哪些模型?
选用当前目录中同时支持 DSPy 所用端点和所需功能的模型。精确 ID、当前单价与能力以 /models 及模型详情为准;不要仅按 GPT、Claude 等系列名判断兼容性。客户端能列出模型,只证明模型发现成功,仍需验证实际调用。
能列出模型,但调用报 400 或 404,怎么办?
先记录实际请求路径和错误消息,再核对精确模型 ID。400 可能是参数、工具类型或模型与端点不匹配;404 可能是请求路径或资源不存在,不能直接判定模型下线。若错误提示 must be called via,按它指明的端点调整客户端 provider,或换用支持当前端点的模型。不要在所有工具里统一增删 /v1 或 /chat/completions。
中国大陆能直连吗?
能。网关在大陆可直连、无需 VPN,配置与全球环境完全一致。
报 401/402/403/429 怎么排查?
先到 Dashboard → Logs 核对请求及错误消息。401 查 Key 是否传入和有效;402 查钱包余额与 maxSpend;403 查 Key 权限和访问限制;429 查请求频率、token 限额及上游限流,按错误来源处理。保留 request_id,再按错误码速查页逐项排查。