LLM 客户端
pkg/core/llm 只做一件事:让业务用一份 canonical 的
Request / Response 说话,由客户端负责把它翻成厂商的
wire 协议。业务不 import 任何厂商 SDK。
canonical 形态
type Request struct {
Model string
System string // 每种协议都有自己的 system 字段,转换时各自归位
Messages []Message
Tools []Tool
ToolChoice string // "auto" / "none" / "required" / "name:<tool>";空即 auto
MaxTokens int
Temperature *float64
TopP *float64
Stop []string
Stream bool // 由 ChatStream 设置;Chat 强制关掉
}
type Response struct {
ID string
Model string
Content string
ToolCalls []ToolCall
FinishReason string // 归一化:stop / length / tool_calls / content_filter / refusal / other
Usage Usage
}
Message.Role 取 "system" / "user" /
"assistant" / "tool";tool 消息带 ToolCallID
与 Name,assistant 消息带 ToolCalls。
ToolCall.Arguments 永远是紧凑的 JSON 字符串——Anthropic 与 Gemini
在线上把它当对象传,转换时在这里对齐。
四种 wire 协议
| 协议 | 常量 | 端点 | 鉴权头 |
|---|---|---|---|
| OpenAI Chat Completions | ProtocolChat | {base}/chat/completions | Authorization: Bearer |
| OpenAI Responses | ProtocolResponses | {base}/responses | Authorization: Bearer |
| Anthropic Messages | ProtocolAnthropic | {base}/v1/messages | x-api-key + anthropic-version |
| Google Gemini generateContent | ProtocolGemini | {base}/models/{model}:generateContent(流式为 :streamGenerateContent?alt=sse) | x-goog-api-key |
转换是公开 API,双向都通:Request.ToChat() /
ToResponses() / ToAnthropic() / ToGemini()
把一份 canonical 请求渲染成各协议的 wire body;
ParseChat / ParseResponses / ParseAnthropic /
ParseGemini 把各协议的响应读回 canonical Response。
Convert(body, from, to) 则以 canonical 为中转,把任意协议的请求体翻成
任意另一种。
flowchart TD
R["一份 canonical Request
System + 4 条 Message(含 tool call 与 tool 结果)
Tools + ToolChoice + MaxTokens"] --> C["req.ToChat()"]
R --> P["req.ToResponses()"]
R --> A["req.ToAnthropic()"]
R --> G["req.ToGemini()"]
C --> CB["messages[] 数组
system 作为 role:system 消息
tool_calls / tool_call_id"]
P --> PB["input[] 条目
instructions 承载 system"]
A --> AB["system 独立顶层字段
max_tokens 必填
连续 user 消息合并"]
G --> GB["contents[] role 为 user / model
systemInstruction
functionResponse 需要函数名"]
CB --> X["Convert(body, from, to)
经 canonical 中转"]
PB --> X
AB --> X
GB --> X
classDef canon fill:#fdf6f0,stroke:#c8641f,color:#2b2722
class R,X canon
functionResponse 缺函数名时,
会按 ToolCallID 回到之前的 assistant 调用里查,兜底取
Message.Name。
Convert 只覆盖这四种对话协议。Jev 的 System One 有自己的
canonical 类型(DecideRequest / DecideResponse),
不进转换集。
协议是怎么定的
New 在 WithProtocol 为空时按 BaseURL 推断,
规则是纯粹的子串匹配:含 anthropic → Anthropic,含
generativelanguage 或 googleapis → Gemini,含
typesafe → Jev,其余一律 ProtocolChat——
那是多数网关都会说的标准。
WithProtocol,
否则请求会以 Chat 格式打到不认它的端点上。客户端从不发探测请求去猜协议。
选项
| 选项 | 作用 | 默认 |
|---|---|---|
WithBaseURL(u) | 厂商 base URL,尾部 / 会被去掉 | 无(必填) |
WithAPIKey(k) | API key,按协议放到对应的头 | 空 |
WithModel(m) | 默认模型,请求没写 Model 时用它 | 空 |
WithProtocol(p) | 强制 wire 协议,压过 base URL 推断 | 按 base URL 推断 |
WithTimeout(d) | HTTP 客户端超时;流式调用只用它约束"等到响应头" | 60s |
WithMaxTokens(n) | 默认 max tokens | 0(协议各自处理;Anthropic 缺省 4096) |
WithJevVia(v) | System One 的托管形态 | JevViaSystemOne |
WithRetry(cfg) | 只重试"稍后再试"类失败 | 关(Attempts <= 1) |
WithHTTPClient(hc) | 自定义 HTTP 客户端,其 Timeout 原样保留 | 内部构造的 60s 客户端 |
c := llm.New(
llm.WithBaseURL("https://api.openai.com/v1"), // 按域名自动推断协议
llm.WithAPIKey(os.Getenv("OPENAI_API_KEY")),
llm.WithModel("gpt-4o-mini"),
llm.WithTimeout(30*time.Second),
llm.WithMaxTokens(1024),
)
resp, err := c.Chat(ctx, llm.Request{
Messages: []llm.Message{{Role: "user", Content: "hi"}},
})
请求缺 Model 且客户端也没配,返回的是一条明确的错误
(llm: model is required),而不是发出去让上游拒绝。
Close() 释放空闲连接。
流式
ChatStream 返回一个 canonical 事件的 channel,只在建流失败时
返回 error;流中的失败以 EventError 事件到达。
ch, err := c.ChatStream(ctx, req)
if err != nil {
return err
}
defer llm.StopStream(ch) // 提前退出也要释放连接与 goroutine
for ev := range ch {
switch ev.Kind {
case llm.EventText: // 增量文本,在 ev.Text
case llm.EventToolCall: // 一次完整的函数调用,在 ev.ToolCall
case llm.EventUsage: // 累计 token 数,在 ev.Usage
case llm.EventDone: // 流结束,ev.Stop 是归一化的结束原因
case llm.EventError: // 流以失败结束,ev.Err 是原因
}
}
| 事件 | 携带 |
|---|---|
EventText | Text:回复文本的一段增量 |
EventToolCall | ToolCall:一个完整的函数调用(分片在包内已拼好) |
EventUsage | Usage:累计的输入 / 输出 / 总 token |
EventDone | Stop:归一化的结束原因 |
EventError | Err:失败原因 |
超时语义是刻意的:WithTimeout 只约束等到响应头
(time-to-first-byte),响应体开始流动后由调用方的 ctx 决定何时结束。
慢而持续的流不会被从中间截断,调用方仍可用自己的 deadline 限时。
StopStream(ch) 排空并结束流,让读取 goroutine 在没有消费者时
也能退出。它应该紧跟在 ChatStream 之后 defer——
一个被遗弃的流不会永久占住连接。
重试:只针对"稍后再试"类失败
WithRetry 默认关闭。打开后,只有这些 HTTP 状态会被重试:
408、409、425、429、5xx,加上传输层失败(状态未知)。400/401/403/404
立即返回,不会白白多花 token。退避与抖动完全走 pkg/core/retry。
c := llm.New(
llm.WithBaseURL(base), llm.WithAPIKey(key), llm.WithModel("gpt-4o-mini"),
llm.WithRetry(retry.Config{Attempts: 3, BaseDelay: 200 * time.Millisecond}),
)
重试的边界写死在三个地方:
- Chat:整次非流式调用可重试——重发一次对话请求是安全的重读,不是重写。
- ChatStream:只重试建流那一次请求。流到一半断开不重试,因为已收到的部分无法回滚。
- Decide:同样可重试。
重试次数受 ctx 截止时间约束——两者要一起设,
否则要么白白重试、要么重试根本轮不上。
io.LimitReader 读 maxReplyBytes+1 字节,
超过 16MiB 返回 llm: reply exceeds 16MiB。这个上限故意不做
静默截断:被截断的 JSON 会解析错,返回一个"看着合理但是错的"答案比直接失败糟得多。
上游错误统一映射到 errs 种类:401 → Unauthorized、403 → Forbidden、
404 → NotFound、429 → RateLimited、5xx → Unavailable、其余 4xx → InvalidArgument、
超时 → Timeout。上游的响应体节选(最多 300 字符)会进错误消息,方便排查。
Jev 决策问答(System One)
Jev 不回答对话,而是回答带类型的问题,并给出每个选项的把握度,
供路由层判定一条消息属于哪个 intent、这一轮要多重推理。
Client.Decide 说的是 System One,无论客户端的 Protocol
是什么——同一个 provider 可能既提供对话也提供决策。
| 问题类型 | 用到的字段 | 回答 |
|---|---|---|
"noul"(Vercel 上叫 "boolean") | Instructions | Answer.Noul:0..1,"是"的程度 |
"score" | Bands(有序级别,低到高) | Answer.Score:命中级别的下标 |
"choice" | Criteria(选项 → 它是什么) | Answer.Choice 与 Probabilities |
"probability" | Criteria | Answer.Probabilities:各选项的可能性 |
每个回答还带 Confidence——Jev 对最可能那项有多确定。
问题按名字问、按同样的名字答,答案在 DecideResponse.Answers 里。
out, err := jc.Decide(ctx, llm.DecideRequest{
State: map[string]any{"message": "这个查询接口偶尔超时,帮我修一下。"},
Questions: map[string]llm.Question{
"intent": {Type: "choice", Instructions: "Which kind of request fits it best?",
Criteria: map[string]string{"greeting": "", "bug fix": "an ordinary fix"}},
"effort": {Type: "score", Instructions: "How much reasoning?",
Bands: []string{"a one-line change", "an ordinary fix", "a change across files"}},
},
})
// out.Answers["intent"].Choice / .Confidence / .Probabilities
// out.Answers["effort"].Score ; out.Usage
注意 score 的 Bands 和 choice 的 Criteria 在线上都叫
criteria,但形态不同——score 是有序列表,choice 是映射。
所以它在 canonical 类型里是两个字段,不能合并。未命名的 choice 选项(
"")在线上保持 null。
三种托管形态
WithJevVia |
端点 | 差异 |
|---|---|---|
JevViaSystemOne(默认) |
{base}/systemone |
body 原样发——TypeSafe 与 magpie 网关的形态 |
JevViaVercelEval |
{base}/evaluation-model |
模型走 ai-model-id 头并附上网关版本头;noul 以 boolean 问,应答归一为 noul;回包没给 confidence 时由概率算出 |
JevViaCloudflare |
{base}/ai/run(base 是账户 URL,如 https://api.cloudflare.com/client/v4/accounts/<id>,不含 /ai) |
body 包成 {model, input},答案从 result 里解封 |
Decide 没有流式版本:Jev 一次答完。base URL 含 typesafe
时协议推断本来就是 ProtocolJev,但 Decide 不依赖这个——
它总是说 System One,jevVia 决定的是怎么问。
相关示例
- examples/llm:一份请求翻成四种 wire body、进程内假上游跑通 Chat 流式与非流式、假 System One 上游问一次 Jev。全部离线。
- 稳定性防护:
WithRetry用的退避与抖动来自pkg/core/retry。 - 包索引:
llm的导出符号一览。