第6讲:Streaming 优化——告别“转圈圈“的用户体验


一、为什么需要 Streaming

复制代码
非流式(传统方式):
┌──────────────────────────────────────────────┐
│  用户:帮我写一封邮件给客户                    │
│                                              │
│  时间轴:                                     │
│  0s ──── 发送请求                             │
│  5s ──── 还在等                                │
│  10s ─── 还在等                                │
│  15s ─── 收到完整回复(用户已经不耐烦了)      │
└──────────────────────────────────────────────┘

流式(Streaming):
┌──────────────────────────────────────────────┐
│  用户:帮我写一封邮件给客户                    │
│                                              │
│  时间轴:                                     │
│  0s ──── 发送请求                             │
│  1s ──── 看到"好的,"                         │
│  2s ──── 看到"我正在为您起草邮件"              │
│  3s ──── 看到"尊敬的客户,"                    │
│  ... ──── 逐字显示,用户感知几乎无延迟         │
└──────────────────────────────────────────────┘

Streaming 的核心价值:将 TTFB(首字节时间)从 10-20s 降到 1-2s,大幅提升用户体验。


二、SSE 协议基础

SSE(Server-Sent Events)是 Streaming 的标准协议,基于 HTTP 长连接。

复制代码
SSE 数据格式:
───────────────────────────────────────────────
data: {"choices":[{"delta":{"content":"你好"}}]}

data: {"choices":[{"delta":{"content":","}}]}

data: {"choices":[{"delta":{"content":"世界"}}]}

data: [DONE]
───────────────────────────────────────────────

特点:
1. 每个事件以 "data: " 开头
2. 事件之间用空行分隔
3. 以 "[DONE]" 标记结束
4. 浏览器原生支持 EventSource API

三、服务端 Streaming 实现

复制代码
// streaming/server.go
package streaming

import (
    "bufio"
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "sync"
    "time"
)

// StreamEvent SSE 事件
type StreamEvent struct {
    ID      string          `json:"id,omitempty"`
    Event   string          `json:"event,omitempty"`
    Data    json.RawMessage `json:"data"`
    Retry   int             `json:"retry,omitempty"`   // 重连间隔(毫秒)
}

// StreamWriter SSE 写入器
type StreamWriter struct {
    writer  http.ResponseWriter
    flusher http.Flusher
    mu      sync.Mutex
    closed  bool
}

// NewStreamWriter 创建 SSE 写入器
func NewStreamWriter(w http.ResponseWriter) (*StreamWriter, error) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        return nil, fmt.Errorf("streaming not supported")
    }

    // 设置 SSE 必需的响应头
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲

    return &StreamWriter{
        writer:  w,
        flusher: flusher,
    }, nil
}

// WriteEvent 写入一个 SSE 事件
func (sw *StreamWriter) WriteEvent(event StreamEvent) error {
    sw.mu.Lock()
    defer sw.mu.Unlock()

    if sw.closed {
        return fmt.Errorf("stream already closed")
    }

    var buf bytes.Buffer

    if event.ID != "" {
        buf.WriteString(fmt.Sprintf("id: %s\n", event.ID))
    }
    if event.Event != "" {
        buf.WriteString(fmt.Sprintf("event: %s\n", event.Event))
    }
    if event.Retry > 0 {
        buf.WriteString(fmt.Sprintf("retry: %d\n", event.Retry))
    }

    // data 字段支持多行
    dataStr := string(event.Data)
    for _, line := range bytes.Split([]byte(dataStr), []byte("\n")) {
        buf.WriteString(fmt.Sprintf("data: %s\n", line))
    }

    buf.WriteString("\n") // 空行分隔事件

    _, err := sw.writer.Write(buf.Bytes())
    if err != nil {
        return err
    }

    sw.flusher.Flush()
    return nil
}

// WriteData 写入纯数据(最常用)
func (sw *StreamWriter) WriteData(data []byte) error {
    return sw.WriteEvent(StreamEvent{
        Data: data,
    })
}

// WriteDone 写入结束标记
func (sw *StreamWriter) WriteDone() error {
    sw.mu.Lock()
    defer sw.mu.Unlock()

    if sw.closed {
        return nil
    }

    _, err := sw.writer.Write([]byte("data: [DONE]\n\n"))
    sw.closed = true
    if err != nil {
        return err
    }
    sw.flusher.Flush()
    return nil
}

// WriteError 写入错误事件
func (sw *StreamWriter) WriteError(code int, message string) error {
    errData, _ := json.Marshal(map[string]interface{}{
        "error": map[string]interface{}{
            "code":    code,
            "message": message,
        },
    })
    return sw.WriteData(errData)
}

// Close 关闭流
func (sw *StreamWriter) Close() {
    sw.mu.Lock()
    defer sw.mu.Unlock()
    sw.closed = true
}

// ─── LLM Streaming Adapter ───

// LLMStreamAdapter LLM 流式适配器
type LLMStreamAdapter struct {
    provider string
    apiKey   string
    baseURL  string
    client   *http.Client
}

// NewLLMStreamAdapter 创建流式适配器
func NewLLMStreamAdapter(provider, apiKey string) *LLMStreamAdapter {
    return &LLMStreamAdapter{
        provider: provider,
        apiKey:   apiKey,
        baseURL:  getBaseURL(provider),
        client:   &http.Client{Timeout: 60 * time.Second},
    }
}

// ChatStream 流式聊天
func (a *LLMStreamAdapter) ChatStream(
    messages []map[string]string,
    onToken func(token string),
    onDone func(),
    onError func(error),
) {
    reqBody := map[string]interface{}{
        "model":    "gpt-4",
        "messages": messages,
        "stream":   true,
    }

    body, _ := json.Marshal(reqBody)
    httpReq, _ := http.NewRequest("POST", a.baseURL+"/chat/completions",
        bytes.NewReader(body))
    httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
    httpReq.Header.Set("Content-Type", "application/json")

    resp, err := a.client.Do(httpReq)
    if err != nil {
        onError(fmt.Errorf("request failed: %w", err))
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode != 200 {
        bodyBytes, _ := io.ReadAll(resp.Body)
        onError(fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes)))
        return
    }

    reader := bufio.NewReader(resp.Body)
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            if err == io.EOF {
                onDone()
                return
            }
            onError(fmt.Errorf("read error: %w", err))
            return
        }

        line = trimSSELine(line)
        if line == "" || line == "[DONE]" {
            if line == "[DONE]" {
                onDone()
                return
            }
            continue
        }

        // 解析 SSE 数据
        var streamResp struct {
            Choices []struct {
                Delta struct {
                    Content string `json:"content"`
                } `json:"delta"`
                FinishReason string `json:"finish_reason"`
            } `json:"choices"`
        }

        if err := json.Unmarshal([]byte(line), &streamResp); err != nil {
            continue
        }

        for _, choice := range streamResp.Choices {
            if choice.Delta.Content != "" {
                onToken(choice.Delta.Content)
            }
            if choice.FinishReason == "stop" {
                onDone()
                return
            }
        }
    }
}

func getBaseURL(provider string) string {
    switch provider {
    case "openai":
        return "https://api.openai.com/v1"
    case "claude":
        return "https://api.anthropic.com/v1"
    default:
        return "https://api.openai.com/v1"
    }
}

func trimSSELine(line string) string {
    if len(line) > 5 && line[:5] == "data:" {
        line = line[5:]
    }
    line = bytes.TrimSpace([]byte(line))
    return string(line)
}

四、HTTP 端点实现

复制代码
// streaming/handler.go
package streaming

import (
    "encoding/json"
    "log"
    "net/http"
    "time"
)

// StreamChatHandler 流式聊天 HTTP 处理器
type StreamChatHandler struct {
    adapter *LLMStreamAdapter
}

// NewStreamChatHandler 创建处理器
func NewStreamChatHandler(adapter *LLMStreamAdapter) *StreamChatHandler {
    return &StreamChatHandler{adapter: adapter}
}

// ServeHTTP 处理流式聊天请求
func (h *StreamChatHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != "POST" {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // 1. 解析请求
    var req struct {
        Messages []map[string]string `json:"messages"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    // 2. 创建 SSE 写入器
    sw, err := NewStreamWriter(w)
    if err != nil {
        http.Error(w, "Streaming not supported", http.StatusInternalServerError)
        return
    }

    // 3. 监听客户端断开
    notify := r.Context().Done()
    go func() {
        <-notify
        sw.Close()
        log.Println("[Stream] Client disconnected")
    }()

    // 4. 发送开始事件
    startData, _ := json.Marshal(map[string]string{
        "type":    "start",
        "message": "开始生成回复",
    })
    sw.WriteData(startData)

    // 5. 发起流式调用
    tokenCount := 0
    startTime := time.Now()

    h.adapter.ChatStream(
        req.Messages,
        func(token string) {
            // 每个 token 作为一个事件发送
            tokenData, _ := json.Marshal(map[string]interface{}{
                "type":  "token",
                "token": token,
            })
            if err := sw.WriteData(tokenData); err != nil {
                log.Printf("[Stream] Write error: %v", err)
                return
            }
            tokenCount++
        },
        func() {
            // 完成事件
            elapsed := time.Since(startTime)
            doneData, _ := json.Marshal(map[string]interface{}{
                "type":         "done",
                "token_count":  tokenCount,
                "elapsed_ms":   elapsed.Milliseconds(),
                "tokens_per_s": float64(tokenCount) / elapsed.Seconds(),
            })
            sw.WriteData(doneData)
            sw.WriteDone()
            log.Printf("[Stream] Completed: %d tokens in %v (%.1f tok/s)",
                tokenCount, elapsed, float64(tokenCount)/elapsed.Seconds())
        },
        func(err error) {
            log.Printf("[Stream] Error: %v", err)
            sw.WriteError(500, err.Error())
            sw.WriteDone()
        },
    )
}

// ─── 启动 HTTP 服务 ───

func StartStreamingServer(addr string) {
    adapter := NewLLMStreamAdapter("openai", "sk-your-key")
    handler := NewStreamChatHandler(adapter)

    http.Handle("/v1/chat/stream", handler)
    http.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ok"}`))
    }))

    log.Printf("[Stream] Server starting on %s", addr)
    log.Fatal(http.ListenAndServe(addr, nil))
}

五、客户端实现

复制代码
// streaming/client.go
package streaming

import (
    "bufio"
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
    "sync"
)

// StreamClient SSE 客户端
type StreamClient struct {
    baseURL string
    client  *http.Client
}

// NewStreamClient 创建客户端
func NewStreamClient(baseURL string) *StreamClient {
    return &StreamClient{
        baseURL: baseURL,
        client:  &http.Client{},
    }
}

// ChatCompletion 流式聊天(回调方式)
func (c *StreamClient) ChatCompletion(
    messages []map[string]string,
    onToken func(string),
    onDone func(StreamStats),
    onError func(error),
) {
    reqBody, _ := json.Marshal(map[string]interface{}{
        "messages": messages,
    })

    req, _ := http.NewRequest("POST", c.baseURL+"/v1/chat/stream",
        bytes.NewReader(reqBody))
    req.Header.Set("Content-Type", "application/json")

    resp, err := c.client.Do(req)
    if err != nil {
        onError(fmt.Errorf("request failed: %w", err))
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode != 200 {
        body, _ := io.ReadAll(resp.Body)
        onError(fmt.Errorf("server error %d: %s", resp.StatusCode, string(body)))
        return
    }

    // 检查 Content-Type
    ct := resp.Header.Get("Content-Type")
    if !strings.HasPrefix(ct, "text/event-stream") {
        body, _ := io.ReadAll(resp.Body)
        onError(fmt.Errorf("unexpected content type: %s, body: %s", ct, string(body)))
        return
   }

    reader := bufio.NewReader(resp.Body)
    stats := StreamStats{StartTime: now()}

    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            if err == io.EOF {
                onDone(stats)
                return
            }
            onError(fmt.Errorf("read error: %w", err))
            return
        }

        line = strings.TrimSpace(line)
        if !strings.HasPrefix(line, "data: ") {
            continue
        }

        data := strings.TrimPrefix(line, "data: ")
        if data == "[DONE]" {
            onDone(stats)
            return
        }

        var event struct {
            Type        string `json:"type"`
            Token       string `json:"token"`
            TokenCount  int    `json:"token_count"`
            ElapsedMs   int64  `json:"elapsed_ms"`
        }

        if err := json.Unmarshal([]byte(data), &event); err != nil {
            continue
        }

        switch event.Type {
        case "token":
            onToken(event.Token)
            stats.TokenCount++
        case "done":
            stats.TokenCount = event.TokenCount
            stats.ElapsedMs = event.ElapsedMs
            onDone(stats)
            return
        case "error":
            onError(fmt.Errorf("server error: %s", data))
            return
        }
    }
}

// StreamStats 流式统计
type StreamStats struct {
    TokenCount int
    ElapsedMs  int64
    StartTime  time.Time
}

// ─── JavaScript 客户端示例 ───

const jsClientCode = `
// 前端 JS 示例
class StreamChat {
    constructor(url) {
        this.url = url;
        this.abortController = null;
    }

    async send(messages, callbacks) {
        const { onToken, onDone, onError } = callbacks;
        
        try {
            const response = await fetch(this.url, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ messages }),
                signal: this.abortController?.signal
            });

            if (!response.ok) {
                throw new Error('HTTP ' + response.status);
            }

            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            let buffer = '';

            while (true) {
                const { done, value } = await reader.read();
                if (done) break;

                buffer += decoder.decode(value, { stream: true });
                const lines = buffer.split('\n');
                buffer = lines.pop() || '';

                for (const line of lines) {
                    if (!line.startsWith('data: ')) continue;
                    
                    const data = line.slice(6);
                    if (data === '[DONE]') {
                        onDone?.();
                        return;
                    }

                    try {
                        const event = JSON.parse(data);
                        switch (event.type) {
                            case 'token':
                                onToken?.(event.token);
                                break;
                            case 'done':
                                onDone?.(event);
                                break;
                            case 'error':
                                onError?.(new Error(event.message));
                                break;
                        }
                    } catch (e) {
                        // skip invalid JSON
                    }
                }
            }
        } catch (err) {
            if (err.name !== 'AbortError') {
                onError?.(err);
            }
        }
    }

    abort() {
        this.abortController?.abort();
    }
}

// 使用示例
const chat = new StreamChat('/v1/chat/stream');

chat.send([
    { role: 'user', content: '帮我写一封邮件' }
], {
    onToken: (token) => {
        document.getElementById('output').textContent += token;
    },
    onDone: () => {
        console.log('完成');
    },
    onError: (err) => {
        console.error('错误:', err);
    }
});
`

六、流式场景下的工具调用

复制代码
// streaming/fcall.go
package streaming

import (
    "encoding/json"
    "fmt"
    "log"
    "strings"
)

// StreamFCallHandler 流式 Function Calling 处理器
type StreamFCallHandler struct {
    fcEngine *fcall.Engine
    adapter  *LLMStreamAdapter
}

// HandleStreamFCall 处理流式场景下的工具调用
func (h *StreamFCallHandler) HandleStreamFCall(
    messages []map[string]string,
    sw *StreamWriter,
) {
    fullResponse := &strings.Builder{}
    pendingToolCalls := make([]*fcall.ToolCall, 0)
    inToolCall := false
    toolCallBuffer := &strings.Builder{}

    h.adapter.ChatStream(
        messages,
        func(token string) {
            // 检测是否进入工具调用模式
            if strings.Contains(token, "<tool_call>") {
                inToolCall = true
                toolCallBuffer.Reset()
                return
            }
            if inToolCall {
                if strings.Contains(token, "</tool_call>") {
                    // 工具调用结束,解析并执行
                    inToolCall = false
                    tc := parseToolCall(toolCallBuffer.String())
                    if tc != nil {
                        pendingToolCalls = append(pendingToolCalls, tc)
                        // 发送工具调用标记给客户端
                        tcData, _ := json.Marshal(map[string]interface{}{
                            "type":     "tool_call",
                            "tool":     tc.Name,
                            "args":     string(tc.Args),
                        })
                        sw.WriteData(tcData)
                    }
                    return
                }
                toolCallBuffer.WriteString(token)
                return
            }

            // 普通 token,转发给客户端
            fullResponse.WriteString(token)
            tokenData, _ := json.Marshal(map[string]interface{}{
                "type":  "token",
                "token": token,
            })
            sw.WriteData(tokenData)
        },
        func() {
            // 首次生成完成,如果有工具调用则执行
            if len(pendingToolCalls) > 0 {
                log.Printf("[StreamFCall] Executing %d tool calls", len(pendingToolCalls))
                
                // 执行工具
                results := h.fcEngine.ExecuteToolCalls(nil, pendingToolCalls)
                
                // 将工具结果加入消息
                for i, tr := range results {
                    resultJSON, _ := json.Marshal(tr.Result)
                    messages = append(messages, map[string]string{
                        "role":    "tool",
                        "content": string(resultJSON),
                    })
                    
                    // 发送工具结果给客户端
                    resData, _ := json.Marshal(map[string]interface{}{
                        "type":   "tool_result",
                        "tool":   tr.Name,
                        "result": tr.Result,
                    })
                    sw.WriteData(resData)
                }

                // 第二次流式生成
                h.adapter.ChatStream(
                    messages,
                    func(token string) {
                        fullResponse.WriteString(token)
                        tokenData, _ := json.Marshal(map[string]interface{}{
                            "type":  "token",
                            "token": token,
                        })
                        sw.WriteData(tokenData)
                    },
                    func() {
                        // 最终完成
                        doneData, _ := json.Marshal(map[string]interface{}{
                            "type":         "done",
                            "full_response": fullResponse.String(),
                        })
                        sw.WriteData(doneData)
                        sw.WriteDone()
                    },
                    func(err error) {
                        sw.WriteError(500, err.Error())
                        sw.WriteDone()
                    },
                )
                return
            }

            // 没有工具调用,直接完成
            doneData, _ := json.Marshal(map[string]interface{}{
                "type":         "done",
                "full_response": fullResponse.String(),
            })
            sw.WriteData(doneData)
            sw.WriteDone()
        },
        func(err error) {
            sw.WriteError(500, err.Error())
            sw.WriteDone()
        },
    )
}

// parseToolCall 解析工具调用标记
func parseToolCall(raw string) *fcall.ToolCall {
    raw = strings.TrimSpace(raw)
    
    // 期望格式: {"name": "xxx", "args": {...}}
    var tc struct {
        Name string          `json:"name"`
        Args json.RawMessage `json:"args"`
    }
    
    if err := json.Unmarshal([]byte(raw), &tc); err != nil {
        log.Printf("[StreamFCall] Failed to parse tool call: %v", err)
        return nil
    }
    
    return &fcall.ToolCall{
        ID:   fmt.Sprintf("call_stream_%d", time.Now().UnixNano()),
        Name: tc.Name,
        Args: tc.Args,
    }
}

七、性能优化

复制代码
// streaming/optimize.go
package streaming

import (
    "sync"
    "time"
)

// TokenBucket 令牌桶限流(发送频率控制)
type TokenBucket struct {
    rate       float64
    burst      int
    tokens     float64
    lastRefill time.Time
    mu         sync.Mutex
}

func NewTokenBucket(rate float64, burst int) *TokenBucket {
    return &TokenBucket{
        rate:       rate,
        burst:      burst,
        tokens:     float64(burst),
        lastRefill: time.Now(),
    }
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    tb.tokens = min(float64(tb.burst), tb.tokens+elapsed*tb.rate)
    tb.lastRefill = now

    if tb.tokens >= 1 {
        tb.tokens--
        return true
    }
    return false
}

// BatchSender 批量发送器(合并小 token 减少 I/O)
type BatchSender struct {
    sw       *StreamWriter
    buffer   []byte
    maxBatch int
    timer    *time.Timer
    mu       sync.Mutex
}

func NewBatchSender(sw *StreamWriter, maxBatch int, flushInterval time.Duration) *BatchSender {
    bs := &BatchSender{
        sw:       sw,
        maxBatch: maxBatch,
    }
    bs.timer = time.AfterFunc(flushInterval, bs.flush)
    return bs
}

func (bs *BatchSender) Add(token string) {
    bs.mu.Lock()
    defer bs.mu.Unlock()

    bs.buffer = append(bs.buffer, []byte(token)...)
    if len(bs.buffer) >= bs.maxBatch {
        bs.flushNow()
    }
}

func (bs *BatchSender) flush() {
    bs.mu.Lock()
    defer bs.mu.Unlock()
    bs.flushNow()
}

func (bs *BatchSender) flushNow() {
    if len(bs.buffer) == 0 {
        return
    }
    
    data, _ := json.Marshal(map[string]interface{}{
        "type":  "batch",
        "tokens": string(bs.buffer),
    })
    bs.sw.WriteData(data)
    bs.buffer = bs.buffer[:0]
}

func (bs *BatchSender) Close() {
    bs.timer.Stop()
    bs.flush()
}

// 心跳机制(防止代理/负载均衡器断开连接)
func startHeartbeat(sw *StreamWriter, interval time.Duration, stop chan struct{}) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            sw.WriteEvent(StreamEvent{
                Event: "heartbeat",
                Data:  json.RawMessage(`{}`),
            })
        case <-stop:
            return
        }
    }
}

八、生产坑位

# 坑位 现象 解决方案
1 代理缓冲​ Nginx/反向代理缓存 SSE 数据,客户端收不到流式效果 设置 X-Accel-Buffering: no 头
2 连接断开​ 用户刷新页面或网络波动导致连接中断 客户端自动重连 + 服务端心跳保活
3 并发写入​ 多个 goroutine 同时写入 ResponseWriter 导致 panic 用 mutex 保护写入操作
4 内存泄漏​ 客户端断开后 goroutine 仍在运行 监听 Context.Done() 及时清理资源
5 Token 碎片​ 每个 token 单独发送导致大量小包 I/O 合并批次发送(100ms 窗口或累积 50 字符)
6 工具调用时序​ 流式过程中插入工具调用,打乱前端展示逻辑 使用事件类型区分 token/tool_call/tool_result

九、本讲小结

复制代码
┌─────────────────────────────────────────────────────────────┐
│                     第6讲 核心收获                            │
│                                                              │
│  ✅ 理解了 SSE 协议原理和数据格式                            │
│  ✅ 实现了服务端 StreamWriter(写入/心跳/关闭)              │
│  ✅ 搭建了 LLM 流式适配器(OpenAI 兼容)                    │
│  ✅ 实现了 HTTP 端点 + 客户端(Go/JavaScript)              │
│  ✅ 实现了流式场景下的 Function Calling 集成                │
│  ✅ 完成了性能优化(令牌桶限流、批量发送、心跳保活)        │
│  ✅ 了解了 6 个常见坑位及其解决方案                         │
│                                                              │
│  核心理念:Streaming 将用户等待时间从"转圈圈"变为"逐字阅读" │
└─────────────────────────────────────────────────────────────┘

下一讲预告:《可观测性与 AI 监控------你的 Agent 到底在干什么》

你会学到:LLM 调用链路追踪、Token 用量监控、响应质量评估、告警体系搭建、日志审计与合规。

准备好了告诉我 "继续"。

相关推荐
传奇开心果编程12 小时前
【Flutter踩坑排雷】第7课:巨型 build 函数坑|全部 UI 写在同一个 build,维护难、隐性异常
android·flutter·ios·移动开发·跨平台·dart·声明式ui
瓦瓦gogo17 小时前
第一次了解 iOS 开发:从 Swift、SwiftUI 到公共组件
ios·swiftui·swift
Liben20 小时前
Weex开发踩坑记录
android·前端·ios
season_zhu1 天前
Skills For Real Engineers 系列之三:需求聊清楚了,怎么一步步走主流程,做到交付?
ios·ai编程
茶底世界之下1 天前
窗边花瓶太暗?用 Awaking 看提亮室内的代价
ios
2501_915909061 天前
Windows 上用 VS Code 调试 iPhone 真机 App,KXBuild 断点、崩溃定位实操
ide·windows·vscode·ios·iphone·个人开发·敏捷流程
2501_916007472 天前
抓包工具功能对比,TraceEagle、Charles、Fiddler、Wireshark、mitmproxy 差别
前端·网络协议·ios·adb·https·fiddler·wireshark
2501_915106322 天前
Windows 上开发 Unity iOS,使用 KXBuild 进行真机调试、性能采样与打包 ipa
windows·vscode·unity·ios·个人开发·swift·敏捷流程
柚鸥ASO优化2 天前
iOS 27.2 更新后,App Store 广告展示逻辑出现新变化
ios