CI重复运行会无意义消耗免费模型配额,将模型调用移至独立边车服务,仅在必要时触发,可避免配额浪费并提升CI稳定性。
CI 是调用模型的糟糕场所。我把这个判决移到了一个免费的服务器 Sidecar 上。
CI 是一个糟糕的地方来重复调用模型。流水线可以在每次 push、每次重试、每次定时触发时重新运行同一个 job。如果 job 中包含了直接调用模型请求,一个未变化的 prompt 会一次又一次地命中相同的免费路由。流水线看起来仍然是绿的,但免费配额已经没了。
我把那个调用移出了 CI。判决现在驻留在 MonkeyCode 免费服务器选项托管的一个小型 sidecar 后面。CI 调用 sidecar;sidecar 只在必要时才调用免费模型路由。
披露:本文是作为 MonkeyCode 产品推广的一部分准备的。
真正的失败是位置问题,不是模型问题
模型路由没有坏。问题是调用者。
CI job 是一个糟糕的网络客户端。它会重复、重试,并在时间压力下运行。当 job 直接调用免费路由时,每次重新运行都是一个新的上游请求,即使 prompt 没有变化。
这产生了三个成本:
配额消耗却没有新信息
原本确定性的 job 内部出现了不稳定的延迟
流水线有了更广泛的攻击和失败面
Sidecar 修复了位置问题。它把重复的部分从 CI 那里拿走,让模型路由保持在一个本地契约后面。
Sidecar 有三个职责
规范化:将提供者响应转换为单一的 ok / text 形状。
缓存:在短时间窗口内对相同的 prompt 复用上一个好的判决。
故障关闭:当上游响应为空、格式错误或缺失时返回 502。
CI 不需要理解提供者的 JSON、重试逻辑或模型名称。它只需要一个本地端点。
这只用到了 Go 标准库。这对免费服务器槽位很重要,因为没有额外的构建依赖需要安装。
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"sync"
"time"
)
type UpstreamRequest struct {
Prompt string `json:"prompt"`
}
type UpstreamResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
type Verdict struct {
OK bool `json:"ok"`
Text string `json:"text"`
Source string `json:"source"`
Cached bool `json:"cached"`
ElapsedMS int64 `json:"elapsed_ms"`
}
type cacheEntry struct {
Verdict Verdict
ExpiresAt time.Time
}
var (
upstreamURL = os.Getenv("UPSTREAM_URL")
apiKey = os.Getenv("UPSTREAM_API_KEY")
modelName = os.Getenv("UPSTREAM_MODEL")
cacheTTL = 10 * time.Minute
cacheMu sync.Mutex
cache = make(map[string]cacheEntry)
)
func keyFor(prompt string) string {
sum := sha256.Sum256([]byte(prompt))
return hex.EncodeToString(sum[:])
}
func cachedVerdict(key string) (Verdict, bool) {
cacheMu.Lock()
defer cacheMu.Unlock()
entry, ok := cache[key]
if !ok || time.Now().After(entry.ExpiresAt) {
return Verdict{}, false
}
return entry.Verdict, true
}
func storeVerdict(key string, verdict Verdict) {
cacheMu.Lock()
defer cacheMu.Unlock()
cache[key] = cacheEntry{Verdict: verdict, ExpiresAt: time.Now().Add(cacheTTL)}
}
func callUpstream(prompt string) (Verdict, error) {
payload := map[string]any{
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
if modelName != "" {
payload["model"] = modelName
}
body, err := json.Marshal(payload)
if err != nil {
return Verdict{}, err
}
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest(http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil {
return Verdict{}, err
}
req.Header.Set("Content-Type", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
started := time.Now()
resp, err := client.Do(req)
if err != nil {
return Verdict{}, err
}
defer resp.Body.Close()
elapsed := time.Since(started).Milliseconds()
if resp.StatusCode != http.StatusOK {
return Verdict{OK: false, ElapsedMS: elapsed}, nil
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return Verdict{}, err
}
var parsed UpstreamResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return Verdict{OK: false, ElapsedMS: elapsed}, nil
}
text := ""
if len(parsed.Choices) > 0 {
text = parsed.Choices[0].Message.Content
}
if text == "" {
return Verdict{OK: false, ElapsedMS: elapsed}, nil
}
return Verdict{OK: true, Text: text, Source: "upstream", ElapsedMS: elapsed}, nil
}
func handleVerdict(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var request UpstreamRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Prompt == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
key := keyFor(request.Prompt)
if verdict, ok := cachedVerdict(key); ok {
verdict.Cached = true
writeJSON(w, http.StatusOK, verdict)
return
}
verdict, err := callUpstream(request.Prompt)
if err != nil {
http.Error(w, "upstream unavailable", http.StatusBadGateway)
return
}
if verdict.OK {
storeVerdict(key, verdict)
}
if !verdict.OK {
writeJSON(w, http.StatusBadGateway, verdict)
return
}
writeJSON(w, http.StatusOK, verdict)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
go run verdict_sidecar.go
curl -X POST http://localhost:8080/v1/verdict -H 'Content-Type: application/json' -d '{"prompt":"Is this test failure likely environmental?"}'
首次响应预期:
{"ok":true,"text":"...","source":"upstream","cached":false,"elapsed_ms":812}
第二次相同调用应返回相同文本,cached:true,且 elapsed_ms 低得多。
GitLab job 看到的内容
model-verdict:
image: curlimages/curl:latest
variables:
SIDECAR_URL: "http://your-sidecar-host:8080/v1/verdict"
script:
- curl --fail-with-body -X POST "$SIDECAR_URL" -H 'Content-Type: application/json' -d '{"prompt":"Does this error look like a flake?"}' -o model_verdict.json
- cat model_verdict.json
artifacts:
paths:
- model_verdict.json
when: always
CI job 从来看不到提供者。它只看到 sidecar 的规范化判决。
缓存是一个安全阀,不是真相来源
我用 prompt 的 SHA-256 作为缓存 key。相同的 prompt,相同的 key。
缓存防止相同的重新运行消耗配额。但如果上游出现故障,sidecar 返回上一个好的答案,看起来仍然是健康的。
对我来说这是可接受的。对于故障关闭的新鲜度需求,将 cacheTTL 设置为非常低或跳过缓存。权衡是明确的。
这张表是完整契约。CI 只需要检查 curl 退出码。
这个 sidecar 不做什么
它不评判答案质量。
它不选择最佳模型。
它不保护敏感输入。
上面的示例中它不验证调用者身份。
它不会让慢的免费路由变快。
它只是阻止 CI 通过一个形状不好的客户端重复原始模型调用。
谁应该跳过这个模式
有严格新鲜度要求的团队:短缓存可能掩盖提供者漂移。
敏感 prompt:不要通过未认证的本地端点发送那些。
生产决策门控:这是一个确定性的冒烟检查,不是模型评估。
多区域设置:单个 sidecar 增加了单点故障。
对于这些情况,将模型调用保留在带有认证、隐私控制和明确新鲜度的正确评估工具中。
本地契约才是重点
Sidecar 并不复杂。它是一个狭窄的契约边界,介于可重复的 CI job 和非确定性的免费路由之间。
如果你已经有免费服务器槽位,将 sidecar 部署在你的路由旁边,让 CI 调用 /v1/verdict 而不是提供者。模型留在门后。CI 保持轻量。