Claude Code 企业级降本实践:SageMaker 部署开源模型 + LiteLLM 动态路由的工程化方案

Claude Code 在团队里推了两个月,从最初 3 个人试用到 15 人全员使用,Token 费用涨了 4 倍。

成本之外还有安全问题。金融业务同事问:代码发到外部 API,安全审计能过吗?

这两个问题可以用同一套方案解决。基于 Amazon SageMaker 部署开源模型 + LiteLLM Proxy 动态路由,把支线任务分流到私有化模型。跑了两周,成本降约 70%。

这篇文章从工程角度把方案拆解一遍,重点放在踩过的坑上。

问题拆解:Token 消耗结构分析

做方案前先分析了一周调用日志。

Claude Code 在执行一个任务时,会自动拆分成两类调用:

主线任务——用户直接发起的核心工作:代码重构、架构设计、复杂 bug 排查

支线任务——自动触发的辅助操作:会话标题生成、Bash 命令描述、Hook 条件评估、下一步建议

支线任务特征:输入输出格式固定、上下文短、推理逻辑简单。总 Token 消耗占比 60% 以上

主线和支线对 Prompt Cache 依赖性不同。主线依赖完整项目上下文,切模型导致 cache 失效。支线是独立短上下文调用,切换模型几乎没有代价。

整体架构

Claude Code → LiteLLM Proxy (Task Router)
                   ├── 主线 → Amazon Bedrock (Claude Sonnet)
                   └── 支线 → Amazon SageMaker (Kimi/GLM)

几个设计决策:

  1. 为什么用 LiteLLM:企业内模型网关事实标准,100+ 供应商,内置审计和 fallback
  2. 为什么用 SageMaker:托管服务运维成本低,弹性扩缩容开箱即用
  3. 为什么选 SGLang:原生支持 SageMaker Inference API,不用写适配层

关于私有化程度:混合方案。支线在 VPC 内 SageMaker 处理,不出内网。主线走 Amazon Bedrock,有 VPC Endpoint、SOC2/ISO27001 认证。如有"零出境"需求,可把主线也路由到 SageMaker 上的强模型,但复杂推理效果会打折。

工程实现

模型部署

git clone https://github.com/ybalbert001/claude-code-aws-skills.git
cd claude-code-aws-skills/skills/sglang-deploy

python deploy.py \
  --model-id kimi-k2.5 \
  --instance-type ml.p5.48xlarge \
  --endpoint-name kimi-endpoint \
  --region us-east-1

模型选择:推荐 Kimi-K2.5 或 GLM-5,代码能力在开源领域很能打。

踩坑记录:第一次部署用了 ml.g5.12xlarge,直接 OOM。先算显存需求,实例宁大勿小。

LiteLLM 配置

核心配置文件:

# config.yaml
general_settings:
  store_model_in_db: true
  master_key: "sk-your-master-key"

router_settings:
  timeout: 180

litellm_settings:
  callbacks:
    - "stream_anthropic_schema_fixer.hook"
    - "dynamic_tagging_handler.proxy_handler_instance"

model_list:
  - model_name: sagemaker-kimi-2-5
    litellm_params:
      model: sagemaker-chat/kimi-endpoint
      aws_region_name: us-east-1
      timeout: 180
      max_tokens: 8192
      drop_params: true
  - model_name: bedrock-claude-sonnet46
    litellm_params:
      model: bedrock/anthropic.claude-sonnet-4-6-v1:0
      aws_region_name: us-west-2
      timeout: 300

容器化部署:

# docker-compose.yml
services:
  litellm:
    image: ghcr.io/berriai/litellm:v1.82.3-stable
    restart: always
    volumes:
      - ./config.yaml:/app/config.yaml
      - ./stream_anthropic_schema_fixer.py:/app/stream_anthropic_schema_fixer.py:ro
      - ./dynamic_tagging_handler.py:/app/dynamic_tagging_handler.py:ro
    command:
      - "--config=/app/config.yaml"
    ports:
      - "8080:4000"
    environment:
      DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm"
      STORE_MODEL_IN_DB: "True"
      ENABLE_ANTHROPIC_SCHEMA_FIX: "true"
    env_file:
      - .env
    depends_on:
      - db

  db:
    image: postgres:16
    restart: always
    environment:
      POSTGRES_USER: llmproxy
      POSTGRES_PASSWORD: dbpassword9090
      POSTGRES_DB: litellm
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Claude Code 对接

环境变量配置:

alias cc_proxy="ANTHROPIC_API_KEY=sk-your-litellm-key \
ANTHROPIC_BASE_URL=http://your-litellm-host:8080 \
ANTHROPIC_DEFAULT_SONNET_MODEL=bedrock-claude-sonnet46 \
ANTHROPIC_DEFAULT_HAIKU_MODEL=bedrock-claude-haiku45 \
CLAUDE_CODE_SUBAGENT_MODEL=bedrock-claude-sonnet45 \
claude"

ANTHROPIC_BASE_URL 指向 LiteLLM Proxy,ANTHROPIC_DEFAULT_*_MODEL 映射 config.yaml 里的 model_name。开发者无感。

动态路由 Hook

方案核心。LiteLLM Callback Handler 在 API 调用前拦截:

# dynamic_tagging_handler.py
from litellm.integrations.custom_logger import CustomLogger


class DynamicRoutingHandler(CustomLogger):
    def log_pre_api_call(self, kwargs, response_obj, start_time, end_time):
        messages = kwargs.get("messages", [])
        full_text = self._extract_all_text(messages)

        task_model = self._detect_task_type(full_text)
        if task_model:
            kwargs["model"] = task_model
        return kwargs

    def _extract_all_text(self, messages):
        text_parts = []
        for msg in messages:
            content = msg.get("content", "")
            if isinstance(content, str):
                text_parts.append(content)
            elif isinstance(content, list):
                for block in content:
                    if block.get("type") == "text":
                        text_parts.append(block.get("text", ""))
        return " ".join(text_parts)

    def _detect_task_type(self, text):
        if self._is_hook_evaluator(text):
            return "sagemaker-kimi-2-5"
        elif self._is_session_title(text):
            return "sagemaker-kimi-2-5"
        elif self._is_bash_desc(text):
            return "sagemaker-kimi-2-5"
        elif len(text) > 10000:
            return "bedrock-claude-sonnet46"
        return None

    def _is_hook_evaluator(self, text):
        markers = [
            "You are evaluating a hook in Claude Code",
            "hook condition",
            "Return your evaluation as a JSON object",
            '"satisfied": true'
        ]
        return sum(1 for m in markers if m in text) >= 3

    def _is_session_title(self, text):
        return "Generate a short title" in text

    def _is_bash_desc(self, text):
        return "Describe what this bash command does" in text


proxy_handler_instance = DynamicRoutingHandler()

设计要点:多特征阈值匹配。4 个特征至少 3 个命中才路由。早期单特征匹配误判约 8%,改多特征后降到 1% 以下。

Streaming Schema 修复(工程难点)

这个花了两天。

Claude Code 流式解析器严格按 Anthropic Messages API 设计。开源模型的流式数据会缺字段(cache_creation_input_tokensusage)。解析失败后 fallback 到非流式模式,SageMaker 容易 60 秒超时。

解决方案:在 async_post_call_streaming_iterator_hook 里逐 chunk 修复 Schema:

# stream_anthropic_schema_fixer.py
from litellm.integrations.custom_logger import CustomLogger
from typing import AsyncGenerator, Optional, Dict, Any


class AnthropicSchemaFixerHook(CustomLogger):
    async def async_post_call_streaming_iterator_hook(
        self, user_api_key_dict, response: AsyncGenerator,
        request_data: dict
    ) -> AsyncGenerator:
        last_usage = None
        async for chunk in response:
            if not isinstance(chunk, bytes):
                yield chunk
                continue
            try:
                decoded = chunk.decode("utf-8")
                if not decoded.startswith("event:"):
                    yield chunk
                    continue

                event_type, data_json = self._parse_sse(decoded)
                modified = False

                if event_type == "message_start":
                    modified = self._fix_message_start(data_json)
                elif event_type == "message_delta":
                    modified, usage = self._fix_message_delta(data_json)
                    if usage:
                        last_usage = usage
                elif event_type == "message_stop":
                    modified = self._fix_message_stop(
                        data_json, last_usage
                    )

                if modified:
                    yield self._rebuild_sse(event_type, data_json)
                else:
                    yield chunk
            except Exception:
                yield chunk


hook = AnthropicSchemaFixerHook()

思路:拦截 SSE → 解析 JSON → 按事件类型补字段 → 重编码。非侵入式,不改 LiteLLM 源码。

运行效果

两周实测数据:

指标 结果
支线任务路由占比 ~60%-65%
成本降低 ~70%
性价比提升 ~3.2 倍
开发者体验 无感知

工程踩坑记录

  1. OOMml.g5.12xlarge 跑不了大模型,换 ml.p5.48xlarge
  2. Schema 不兼容:Claude Code 更新快,Hook 需持续维护
  3. 路由误判:多特征阈值(≥3 命中),早期 8% → 改后 <1%
  4. 冷启动:建议配 provisioned concurrency
  5. LiteLLM 版本:锁定 v1.82.3-stable

参考资料

方案跑了两周,总体稳定。下一步把路由规则配置化,也把总结、翻译纳入分流。开源模型能力进步快,能分流的范围只会更大。

posted @ 2026-04-07 08:01  亚马逊云开发者  阅读(18)  评论(0)    收藏  举报