API 密钥的工程化管理

这篇讲什么

密钥这东西,写 demo 时随手贴在代码里,等项目上了规模就开始出问题:谁也说不清某把 Key 是哪个服务在用,日志里躺着完整明文,交接时靠聊天工具互传,仓库历史里还埋着两年前提交的旧密钥。

这篇不讲密钥从哪来,只讲拿到之后在工程侧怎么管。按「存放 → 启动校验 → 日志打码 → 分环境隔离 → 作废轮换 → 提交前扫描」六步走,每步都有可直接落地的代码。

环境准备

bash 复制代码
pip install python-dotenv         # 本地读取 .env
pip install detect-secrets        # 提交前扫描(可选,第六步用)

第一步:密钥只从环境变量读,不进代码

先立一条硬规矩:任何密钥值不出现在任何被 git 跟踪的文件里

项目根目录放两个文件,一个真实的、一个模板:

复制代码
.env               # 真实值,加进 .gitignore,永不提交
.env.example       # 只有键名没有值,提交进仓库

.env

复制代码
LLM_API_KEY=sk-real-value-here
LLM_BASE_URL=https://your-gateway.example.com/openai/v1
DB_PASSWORD=another-real-secret

.env.example

复制代码
LLM_API_KEY=
LLM_BASE_URL=
DB_PASSWORD=

.gitignore 里务必包含:

复制代码
.env
.env.*
!.env.example

注意第三行------先忽略所有 .env.*,再用 ! 把模板文件放回来。顺序反了模板就传不上去,新同事 clone 下来不知道该配哪些键。

读取侧:

python 复制代码
from dotenv import load_dotenv
import os

load_dotenv()                      # 生产环境没有 .env 文件时静默跳过,不报错
api_key = os.environ["LLM_API_KEY"]

load_dotenv() 的设计很适合这个场景:本地有 .env 就加载,线上没有就什么也不做,直接用容器/CI 注入的真实环境变量。同一份代码两种环境都跑得通,不需要判断 if is_local

第二步:启动期就校验,别等第一次调用

最难查的故障是「服务启起来了,跑了二十分钟,第一个真实请求进来才报鉴权失败」。密钥缺失属于配置错误,应该在进程启动的前几行就暴露。

python 复制代码
# config.py
import os
import sys

REQUIRED_VARS = (
    "LLM_API_KEY",
    "LLM_BASE_URL",
    "DB_PASSWORD",
)

EX_CONFIG = 78          # sysexits.h 约定:配置错误


def load_config() -> dict:
    missing = [name for name in REQUIRED_VARS if not os.environ.get(name)]
    if missing:
        print(
            "[fatal] 以下必需环境变量未设置:\n  - "
            + "\n  - ".join(missing)
            + "\n请参考 .env.example 补齐后重启。",
            file=sys.stderr,
        )
        sys.exit(EX_CONFIG)

    return {name: os.environ[name] for name in REQUIRED_VARS}


CONFIG = load_config()

在应用入口第一行 from config import CONFIG,缺变量就立刻退出。

退出码选 78 不是随意的。0 是成功、1 是笼统失败,而 78(EX_CONFIG)在 sysexits 约定里专指配置错误。这样容器编排和 CI 能直接根据退出码判断:78 不需要重试,因为重启一百次配置还是缺的;而网络类失败重试往往有效。区分开之后,K8s 的 restartPolicy 和流水线的重试策略才有意义。

补一个校验强度更高的版本,顺手查格式:

python 复制代码
def validate_key_format(key: str) -> bool:
    """基本形态校验,拦住把整行 `LLM_API_KEY=sk-xxx` 粘进值里这种错。"""
    if "=" in key or " " in key or key.startswith(("'", '"')):
        return False
    return len(key) >= 20

引号被一起粘进值里是极高频的错误,报出来的是 401,但你会盯着密钥本身找半天。

第三步:日志里的密钥必须打码

密钥进日志的路径比想象的多:打印配置、打印请求头、异常堆栈带上了完整 URL(有些平台的 Key 会出现在 query 里)、以及最隐蔽的------把整个 client 对象 repr 出来。

写一个打码函数,任何要输出的敏感值都先过一遍:

python 复制代码
def mask(secret: str, keep_head: int = 4, keep_tail: int = 4) -> str:
    """sk-abcd1234...wxyz -> sk-a***wxyz"""
    if not secret:
        return "<empty>"
    if len(secret) <= keep_head + keep_tail:
        return "*" * len(secret)
    return f"{secret[:keep_head]}{'*' * 6}{secret[-keep_tail:]}"


print(f"已加载密钥: {mask(CONFIG['LLM_API_KEY'])}")
# 输出: 已加载密钥: sk-a******wxyz

保留头尾几位是有意的:足以让你在多把 Key 之间对上是哪一把,又不足以复原。全打成星号在排查「线上用的到底是哪把 Key」时会很难受。

想更彻底一点,给 logging 挂一个过滤器,全局兜底:

python 复制代码
import logging
import re

SECRET_PATTERN = re.compile(r"sk-[A-Za-z0-9_\-]{16,}")


class SecretFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        if isinstance(record.msg, str):
            record.msg = SECRET_PATTERN.sub("sk-***REDACTED***", record.msg)
        if record.args:
            record.args = tuple(
                SECRET_PATTERN.sub("sk-***REDACTED***", a) if isinstance(a, str) else a
                for a in record.args
            )
        return True


logging.getLogger().addFilter(SecretFilter())

这一层是保险,不是替代品------正则只认得上它见过的形态。主要防线仍然是「输出前先手动 mask」,过滤器负责捞漏网的。

第四步:按「项目 × 环境」分独立的 Key

不要一把 Key 走天下。给每个组合单独申请一把:

用途 环境变量名 好处
服务 A 生产 LLM_KEY_SVCA_PROD 用量能归因到具体服务
服务 A 测试 LLM_KEY_SVCA_TEST 压测不污染生产统计
本地开发 LLM_KEY_DEV 泄露只影响开发环境
CI 流水线 LLM_KEY_CI 可单独限流,跑挂了不影响线上

这么拆有三个直接收益。归因 :看用量面板就知道是哪个服务在跑,不用靠猜。爆炸半径 :某把 Key 泄露只作废这一把,其他服务毫无感知,不需要全线停机换密钥。排查:线上突然出现异常调用,看是哪把 Key 发的就能立刻锁定来源服务。

大多数平台的控制台都支持一个账户下创建多把密钥并分别查看明细。接入前确认这项能力是否具备------如果只能建一把 Key,上面这套隔离就无从实施,这是选型时容易被忽略的一个硬约束。国内常见的接入平台(如 jiekou.vip)在控制台提供多密钥管理和按密钥查看用量明细的能力,接入前在文档里核对一下即可。

第五步:怀疑泄露就立刻作废重建

「先查查是不是真泄露了,确认了再换」------这个顺序是错的。正确顺序是:先作废,再调查

因为作废重建的代价很低(改一个环境变量、滚动重启),而泄露窗口每多一分钟风险都在累积。等你查清楚,别人可能已经用了半小时。

标准处置流程:

  1. 控制台把可疑的那把 Key 立即作废
  2. 创建新 Key,只更新对应服务的环境变量
  3. 滚动重启该服务,确认恢复正常
  4. 然后再慢慢查:是提交进仓库了、贴到聊天工具里了、还是从日志导出的
  5. 查清根因后补上对应的防护(比如第六步的扫描)

第 2 步「只更新对应服务」正是第四步分开建 Key 换来的------如果全公司共用一把,这一步就变成全服务停机。

关键一点:作废旧 Key 之前,别忘了确认没有其他服务在共用它。 分开建 Key 的项目不会有这个问题;如果历史上有共用,先 grep 一遍配置仓库确认引用范围。

另外,密钥交接一律不传值,只传「去哪个控制台自己建一把」的说明。任何走聊天工具、邮件、共享文档的密钥明文,都等于给自己埋一颗定时炸弹------那些平台的历史记录你无法真正删除。

第六步:pre-commit + CI 双层扫描

人总会手滑,所以要有机器兜底。两层都要有:本地 pre-commit 拦住提交,CI 兜住绕过 hook 的情况(--no-verify 或者直接在网页端编辑)。

本地 hook,.git/hooks/pre-commit

bash 复制代码
#!/bin/sh
# 提交前扫描暂存区里的疑似密钥

PATTERN='sk-[A-Za-z0-9_-]\{16,\}\|api[_-]\?key["'"'"' ]*[:=]["'"'"' ]*[A-Za-z0-9_-]\{16,\}'

if git diff --cached --name-only -z | xargs -0 -r grep -nIE "$PATTERN" 2>/dev/null; then
  echo ""
  echo "[BLOCKED] 暂存区检测到疑似密钥,已阻止提交。"
  echo "请改用环境变量;确认是误报可用 git commit --no-verify 跳过。"
  exit 1
fi
exit 0

记得 chmod +x .git/hooks/pre-commit。注意 grep 加了 -I 跳过二进制文件,否则扫到图片、模型权重会很慢。

CI 侧用成熟工具,比手写正则可靠得多:

yaml 复制代码
# .github/workflows/secret-scan.yml
name: secret-scan
on: [push, pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # 扫全量历史,不只是本次改动
      - name: Install detect-secrets
        run: pip install detect-secrets
      - name: Scan
        run: |
          detect-secrets scan --all-files \
            --exclude-files '\.env\.example$' \
            > .secrets.new
          detect-secrets audit --report .secrets.new

fetch-depth: 0 很关键。密钥的麻烦在于提交进去之后再删掉,它依然躺在 git 历史里,任何能 clone 的人都拿得到。只扫本次 diff 会漏掉这一大类。

扫描器的实用配置是三层叠加:正则匹配已知形态(sk- 前缀这种)、熵值检测抓形态未知的随机串、白名单排除 .env.example 和测试固件里的假值。少了白名单这层,误报多到大家很快就开始无脑 --no-verify,等于没装。

一页速查

环节 做法
存放 环境变量 + .env(gitignore)+ .env.example(提交)
启动 必需变量缺失即退出,退出码 78
日志 输出前 mask(),另挂 logging 过滤器兜底
隔离 按「项目 × 环境」各一把 Key
泄露 先作废再调查,不是先调查再作废
交接 只传获取方式,不传密钥值
防护 pre-commit 拦提交 + CI 扫全量历史

小结

密钥管理不需要引入什么复杂基础设施,以jiekou.vip为例上面六步全部是几十行代码加几个配置文件的量,但覆盖了绝大多数事故场景。优先级排序是:先做第一步和第二步(不进代码、启动校验),改动量最小、收益最大;有多个服务了就补第四步的隔离;团队人数上去了再上第六步的扫描。第三步的日志打码建议一开始就做,因为等日志已经写脏了,回头清理比当初就打码麻烦得多。

相关推荐
宋哥转AI1 小时前
深入理解 AI Agent · AGENT #03:从单 Agent 到多 Agent
人工智能·agent·ai编程
Zara_a41 小时前
从异常工单到质量闭环:制造企业 AI 协同平台的设计与实现
人工智能·制造
尤水就下1 小时前
聊聊 Prompt 是怎么一路进化到 Harness 的
前端·人工智能·ai编程
小白的成长路程1 小时前
移动版藏内容,AI爬虫直接跳过
人工智能·爬虫
赛逸会展s1 小时前
2027北京AI健康科技与智慧医疗展6月举办:全周期对接体系保障高转化率
大数据·人工智能·科技
武子康1 小时前
机器人接入 AI 连续语音后,端云架构要改什么?
人工智能·llm·agent
MacroZheng1 小时前
程序员看文档神器,装上它,看Spring官方文档就一目了然了!
java·人工智能·后端
水獭比特1 小时前
Pydantic AI 2.33.0 遇上 Anthropic SDK 1.0:别让 httpx2 在运行时才暴雷
人工智能·python
要有锋芒_不要疯忙1 小时前
Agent知识(二)----Prompt Engineer发展与未来
人工智能·agent