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 CompletionsProtocolChat{base}/chat/completionsAuthorization: Bearer
OpenAI ResponsesProtocolResponses{base}/responsesAuthorization: Bearer
Anthropic MessagesProtocolAnthropic{base}/v1/messagesx-api-key + anthropic-version
Google Gemini generateContentProtocolGemini{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
同一份 canonical 请求变成四份 wire body。信息在四个方向上都不丢: tool call 的 arguments 在 Chat / Responses 是字符串、在 Anthropic / Gemini 是对象, 转换时按协议各自成型;Gemini 的 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 tokens0(协议各自处理;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 是原因
    }
}
事件携带
EventTextText:回复文本的一段增量
EventToolCallToolCall:一个完整的函数调用(分片在包内已拼好)
EventUsageUsage:累计的输入 / 输出 / 总 token
EventDoneStop:归一化的结束原因
EventErrorErr:失败原因

超时语义是刻意的: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}),
)

重试的边界写死在三个地方:

重试次数受 ctx 截止时间约束——两者要一起设, 否则要么白白重试、要么重试根本轮不上。

16MiB 回复上限,超了直接报错 非流式回复由 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")InstructionsAnswer.Noul:0..1,"是"的程度
"score"Bands(有序级别,低到高)Answer.Score:命中级别的下标
"choice"Criteria(选项 → 它是什么)Answer.Choice 与 Probabilities
"probability"CriteriaAnswer.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 决定的是怎么问。

相关示例