LLM 服务 Sidecar把 Token 计数和日志采集从主进程拆出来一、推理主进程不该为观测性买单GPU 时间不能花在写日志上vLLM、TGI 等推理框架的核心进程同时承担两个职责执行模型推理以及输出运行日志和指标。在低负载时这不是问题——日志写入是毫秒级的 IO 操作对推理延迟的影响可以忽略。但当并发达到一定量级时日志采集可能成为隐性的性能杀手。具体来说vLLM 默认的日志输出包含每个请求的 prompt token 数、completion token 数、首 token 延迟TTFT和端到端延迟。在 100 个并发流式请求的场景下每秒产生的结构化日志可达数千行。Python 的logging模块在写文件时持有锁——如果日志处理器配置了同步写入磁盘推理主线程会在日志写入时被阻塞。虽然阻塞时间通常小于 1ms但在流式推理的 token-by-token 生成循环中累积效应会导致明显的延迟抖动。更隐蔽的问题是 Token 计费。每次推理完成后从响应中解析 token 计数并上报到计费系统通常是在同一个进程中完成的。这段代码可能涉及 HTTP 请求调用内部计费 API、数据库写入或消息队列发送——任何一个环节的异常超时、重试、网络抖动都会消耗推理进程的资源。基础设施不需要漂亮话。推理主进程的职责是推理不是写日志也不是计费。任何不直接影响模型计算的工作都应该拆到 Sidecar 容器中。二、Sidecar 模式的观测分离架构Sidecar 模式在 Kubernetes 中并不新鲜。Envoy、Dapr 都是典型的 Sidecar 实现。但在 LLM 推理场景中Sidecar 的职责更聚焦拦截推理框架的输出流完成 Token 计数、日志格式化、指标聚合和计费上报。flowchart LR subgraph Pod[Inference Pod] direction TB MainProcess[LLM 推理主进程br/vLLM / TGIbr/GPU 绑定] Sidecar[Observability Sidecarbr/Go 实现br/CPU only] SidecarLogger[日志规范化br/Token 计数br/延迟统计] SidecarMetrics[Prometheusbr/指标暴露] SidecarBilling[计费上报br/异步队列] Sidecar -- SidecarLogger Sidecar -- SidecarMetrics Sidecar -- SidecarBilling end MainProcess --|stdout/stderrbr/结构化日志流| Sidecar MainProcess --|/metricsbr/原始指标| Sidecar SidecarLogger --|格式化日志| LogCollector[中心化日志br/Loki / ES] SidecarMetrics --|聚合指标| Prometheus[Prometheusbr/采集] SidecarBilling --|计费事件| Kafka[Kafkabr/计费 Topic]拆分的核心逻辑推理主进程只写 stdout。不做格式化、不做聚合、不写文件。直接向标准输出和标准错误输出吐结构化 JSON 日志。Sidecar 读取主进程的 stdout。容器运行时containerd/CRI-O会将主容器的日志输出到宿主机文件Sidecar 通过共享卷或 FIFO 管道读取这些日志。Sidecar 完成所有后处理。包括 Token 计数、延迟分布统计、Prometheus 指标更新和计费事件发送。三、生产级 Sidecar 实现3.1 推理主进程的日志标准化推理服务端需要做的唯一改变使用结构化日志格式输出到 stdout而不是用print或非结构化日志。# vllm_server.py 改造 import json import sys import time def log_request_event(event_type: str, request_id: str, **kwargs): 输出结构化请求事件到 stdout供 Sidecar 解析。 event { ts: time.time(), type: event_type, request_id: request_id, **kwargs } print(json.dumps(event), filesys.stdout, flushTrue) # 在请求处理流程中埋点 # request_start 时 log_request_event(request_start, req_id, prompt_tokensprompt_len, modelqwen2-7b) # 首 token 生成时 log_request_event(first_token, req_id, ttft_msfirst_token_time - req_start_time) # 请求完成时 log_request_event(request_finish, req_id, completion_tokenscompletion_len, total_time_mstotal_time)3.2 Sidecar 的 Go 实现Sidecar 使用 Go 编写CPU only不占用 GPU 资源。核心功能读取主进程日志流、实时计算 Token 消耗、暴露 Prometheus 指标和发送计费事件。package main import ( bufio context encoding/json fmt io log net/http os sync time github.com/prometheus/client_golang/prometheus github.com/prometheus/client_golang/prometheus/promauto github.com/prometheus/client_golang/prometheus/promhttp github.com/segmentio/kafka-go ) // RequestEvent 对应推理进程输出的结构化事件。 type RequestEvent struct { Timestamp float64 json:ts Type string json:type RequestID string json:request_id Model string json:model PromptTokens int json:prompt_tokens CompletionTokens int json:completion_tokens TTFTMs float64 json:ttft_ms TotalTimeMs float64 json:total_time_ms } // 定义 Prometheus 指标。 var ( tokenTotal promauto.NewCounterVec(prometheus.CounterOpts{ Name: llm_token_total, Help: Total tokens consumed, partitioned by type and model., }, []string{type, model}) ttftHistogram promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: llm_ttft_seconds, Help: Time to first token latency., Buckets: []float64{0.1, 0.25, 0.5, 0.75, 1, 2, 3, 5, 10, 20}, }, []string{model}) activeRequests promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: llm_active_requests, Help: Number of currently active requests., }, []string{model}) ) // ObservabilitySidecar 从推理进程的 stdout 读取结构化日志并处理。 type ObservabilitySidecar struct { input io.Reader writer *kafka.Writer mu sync.Mutex } func NewSidecar(input io.Reader, billingBrokers []string, billingTopic string) *ObservabilitySidecar { return ObservabilitySidecar{ input: input, writer: kafka.Writer{ Addr: kafka.TCP(billingBrokers...), Topic: billingTopic, Balancer: kafka.Hash{}, // 异步写入不阻塞日志处理。 Async: true, BatchSize: 100, BatchTimeout: 200 * time.Millisecond, }, } } // Run 启动 Sidecar 主循环逐行读取推理进程输出并处理。 func (s *ObservabilitySidecar) Run(ctx context.Context) error { scanner : bufio.NewScanner(s.input) // 推理日志的单行可能很长token 序列输出增大 buffer。 scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) for scanner.Scan() { select { case -ctx.Done(): return ctx.Err() default: } if err : s.handleLine(ctx, scanner.Bytes()); err ! nil { // 单条日志处理失败不应使整个链路中断。 log.Printf(handle log line: %v, err) } } if err : scanner.Err(); err ! nil { return fmt.Errorf(scanner error: %w, err) } return nil } func (s *ObservabilitySidecar) handleLine(ctx context.Context, line []byte) error { var event RequestEvent if err : json.Unmarshal(line, event); err ! nil { // 非 JSON 行如启动日志直接忽略不报错。 return nil } switch event.Type { case request_start: activeRequests.WithLabelValues(event.Model).Inc() case first_token: ttftHistogram.WithLabelValues(event.Model).Observe(event.TTFTMs / 1000.0) case request_finish: activeRequests.WithLabelValues(event.Model).Dec() // 更新 Token 消耗计数。 tokenTotal.WithLabelValues(prompt, event.Model). Add(float64(event.PromptTokens)) tokenTotal.WithLabelValues(completion, event.Model). Add(float64(event.CompletionTokens)) // 异步发送计费事件到 Kafka。 billingMsg : map[string]interface{}{ request_id: event.RequestID, model: event.Model, prompt_tokens: event.PromptTokens, completion_tokens: event.CompletionTokens, timestamp: time.Now().Unix(), } msgBytes, _ : json.Marshal(billingMsg) if err : s.writer.WriteMessages(ctx, kafka.Message{ Key: []byte(event.RequestID), Value: msgBytes, }); err ! nil { // 计费发送失败不应影响推理服务记录错误后继续。 log.Printf(send billing event: %v, err) } } return nil } func main() { // Kubernetes 中 Sidecar 的 stdin 通过共享 FIFO 连接到主进程的 stdout。 // 此处假设通过命名管道传入实际部署中由容器运行时对接。 sidecar : NewSidecar(os.Stdin, []string{kafka-broker:9092}, llm-billing) // 启动 Prometheus HTTP 端点。 go func() { http.Handle(/metrics, promhttp.Handler()) log.Fatal(http.ListenAndServe(:9090, nil)) }() ctx, cancel : context.WithCancel(context.Background()) defer cancel() log.Println(observability sidecar started) if err : sidecar.Run(ctx); err ! nil { log.Fatalf(sidecar exited: %v, err) } }3.3 Kubernetes 部署配置apiVersion: v1 kind: Pod metadata: name: llm-inference spec: # 共享进程命名空间允许 Sidecar 通过 /proc 访问主容器。 shareProcessNamespace: true containers: - name: vllm image: vllm/vllm-openai:latest args: - --model - Qwen/Qwen2-7B-Instruct - --port - 8000 resources: limits: nvidia.com/gpu: 1 volumeMounts: - name: log-pipe mountPath: /var/log/inference - name: observability-sidecar image: observability-sidecar:latest env: - name: LOG_PIPE_PATH value: /var/log/inference/vllm.log resources: limits: cpu: 500m memory: 256Mi volumeMounts: - name: log-pipe mountPath: /var/log/inference volumes: - name: log-pipe emptyDir: {}四、Sidecar 模式的隐性成本跨容器通信的延迟。Sidecar 读取主进程日志依赖于文件系统的共享。emptyDir卷的读写延迟通常在微秒级但对于每秒数千条日志的场景文件 IO 仍然可能成为瓶颈。使用tmpfs类型的emptyDirmedium: Memory可以减少 IO 延迟但会消耗 Pod 的内存配额。日志丢失的风险。如果 Sidecar 容器崩溃或重启在恢复期间主进程产生的日志会丢失——因为主进程只写 stdout不缓存、不重放。对于计费场景这可能意味着部分 Token 消耗未被记录。缓解方案Sidecar 崩溃时触发主进程的日志切换往 stderr 输出告警并暂存到本地文件但会增加主进程的复杂度。Sidecar 自身的内存泄漏。Go 的bufio.Scanner在处理超大日志行时如模型配置的 JSON dumpBuffer 会持续增长。设置MaxScanTokenSize代码中的 2MB可以限制单行大小超过阈值会返回bufio.ErrTooLong错误。此时需要决定是截断日志还是扩容 buffer——两种选择都有代价。不适用场景。如果推理服务已经是单 Pod 单请求的部署模式如对延迟极度敏感的实时交互场景Sidecar 的 CPU 和内存开销可能不够经济。同样如果推理框架本身已经集成了完善的指标暴露如 vLLM 自带的 Prometheus endpointSidecar 的日志处理部分可能是冗余的。此时只需保留计费事件的解析逻辑。五、总结LLM 推理服务中的 Token 计数、日志采集和计费上报不应该挤占推理主进程的资源。使用 Sidecar 容器将这些观测性职责拆分出来收益明确推理主进程零开销。只写 stdout不做格式化、不写文件、不发网络请求。观测逻辑独立迭代。Token 计费规则变更、Prometheus 指标调整、日志格式修改都不需要重启推理容器。故障隔离。计费 Kafka 断连不影响推理服务Prometheus 采集失败不影响推理服务。资源隔离。Sidecar 使用独立的 CPU 和内存限额不会与推理进程争抢 GPU 节点上有限的 CPU 资源。基础设施不需要漂亮话。把 Token 计数从主进程拆出来不是架构洁癖是资源隔离的基本操作。