今天要解决的问题
招聘方看你的项目时,最有说服力的一句话是「这是线上地址,你可以现在就试」。一个只能在作者笔记本上跑的 Agent,和一个任何人点开链接就能用的 Agent,在面试官眼里差别很大。站长整理的 305 条 AI Agent 岗位 JD 里,提到部署、Docker、K8s 或云的,国内占 41%,海外 53%,新加坡和香港更是高达 73%。
今天结束时你应该能:
- 写出一个用 uv 管依赖、非 root 运行、分层缓存合理的 Dockerfile。
- 写一个 Compose 文件,一条命令拉起 API、worker、Postgres、Redis 和带自动 HTTPS 的 Caddy,并且数据库迁移自动执行。
- 管好密钥:不进镜像、不进 git、能轮换,知道到了 K8s 环境对应的做法。
- 选一条部署路线把服务放上公网:国内轻量云(以及备案问题)或海外平台/海外 VPS。
- 配一个最小的 CI/CD:推代码自动跑测试、构建镜像、部署。
- 用 Ollama 在本机跑一个开源模型,接进同一套代码;能用算式说明什么时候自部署模型划算。
预计用时 5–7 小时(第一次买服务器、配域名会花些时间)。前置:Day 36–40 的全部代码。
Dockerfile:小、快、不用 root
先看完整的文件,再逐段解释:
# agent-60-lab/Dockerfile
FROM python:3.12-slim-trixie AS base
# uv 官方文档推荐的安装方式:从官方镜像复制二进制,并钉住版本
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /uvx /bin/
ENV UV_NO_DEV=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONUNBUFFERED=1
WORKDIR /app
# 第一层:只装依赖。pyproject.toml 和 uv.lock 不变,这一层就走缓存
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project
# 第二层:复制代码。改代码只会让这一层重建,几秒钟
COPY . .
RUN uv sync --locked
# 非 root 用户运行
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"
CMD ["uvicorn", "w6.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
FROM python:3.12-slim-trixie AS base本机没有 Docker,这个文件未实跑。基础镜像是 Python 3.12 的精简版,系统是 Debian 13(代号 trixie)。AS base给这个构建阶段起个名字,多阶段构建时可以引用。COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /uvx /bin/直接从 uv 的官方镜像里复制两个二进制文件,不用 pip 安装。ENV UV_NO_DEV=1不装 dev 依赖组(pytest 这类)。UV_COMPILE_BYTECODE=1安装时就把 .py 编译成 .pyc,容器启动时省掉这一步。UV_LINK_MODE=copyuv 默认用硬链接把包从缓存装进 .venv,在容器里缓存和目标不在同一个文件系统时会报警告,改成直接复制。PYTHONUNBUFFERED=1输出不缓冲,docker logs能实时看到日志。WORKDIR /app后面的命令都在 /app 下执行,目录不存在会自动创建。COPY pyproject.toml uv.lock ./先只复制两个依赖文件。RUN uv sync --locked --no-install-project只装依赖,不装项目本身。--locked表示 uv.lock 和 pyproject.toml 对不上就直接失败,不会悄悄更新锁文件。COPY . .再复制全部代码,.dockerignore 里列的文件不会进来。RUN uv sync --locked第二次 sync 只是把项目本身装进去,依赖已经在了,几秒钟就完。RUN useradd --create-home --uid 10001 app && chown -R app:app /app建一个普通用户,把 /app 的所有权交给它。USER app从这里开始,包括容器运行时,都以 app 用户执行。ENV PATH="/app/.venv/bin:$PATH"让 uvicorn、arq 这些命令直接用 .venv 里的版本。EXPOSE 8000只是声明一下,不会真的开放端口。HEALTHCHECK --interval=30s --timeout=5s --retries=3每 30 秒检查一次,单次 5 秒超时,连续 3 次失败才标记为 unhealthy。CMD python -c "import urllib.request;slim 镜像里没有 curl,就用 Python 自带的 urllib 请求 /healthz。返回非 200 时 urlopen 会抛异常,退出码不为 0,就算一次检查失败。CMD ["uvicorn", "w6.app.main:app"JSON 数组写法(exec 形式):uvicorn 是容器里的 1 号进程,能直接收到docker stop发来的 SIGTERM,优雅退出。--forwarded-allow-ips "*"信任所有来源的转发头,前提是只有 Caddy 能连到它。
配一个 .dockerignore,这一步经常被忘,忘了的后果是本地的 .venv、.env 甚至 .git 被打进镜像:
# agent-60-lab/.dockerignore
.venv
.env
.env.*
.git
__pycache__
*.pyc
evals/results
data/raw
几处要说明的地方。
依赖和代码分两层复制,是 Docker 构建提速的基本功。依赖一个月改不了几次,代码每天都改。如果 COPY . . 写在 uv sync 前面,每改一行代码都要重新下载安装全部依赖,在国内网络下一次可能要好几分钟。
uv 的版本钉死在 0.13.0。这是 uv 官方 Docker 指南推荐的做法,我写这篇时文档里给的示例版本就是这个,你学到这里时去 uv 的 GitHub releases 看一眼当前版本再改。不钉版本的话,某天 uv 发了一个行为变化的新版本,你的构建会莫名其妙地挂。
HEALTHCHECK 需要一个 /healthz 接口,Day 36 的代码里没有,加上:
# agent-60-lab/w6/app/main.py(追加)
@app.get("/healthz")
async def healthz():
async with app.state.sessions.pool.connection() as conn:
await conn.execute("SELECT 1")
return {"ok": True}
@app.get("/healthz")下面是在临时数据库里实跑的:用 psycopg_pool 建连接池,用 httpx 直接调 ASGI 应用。数据库正常时返回200 {"ok":true}。async with app.state.sessions.pool.connection() as conn:从连接池借一个连接,块结束时自动还回去。实测把连接池指向一个没有数据库的端口(5999),这里拿不到连接,等到超时后抛异常,接口返回500 Internal Server Error。Docker 的 HEALTHCHECK 看到非 200,就记一次失败。await conn.execute("SELECT 1")最便宜的「数据库还活着吗」查询。
健康检查连一下数据库,而不是只返回 200。只返回 200 的健康检查会在数据库挂掉时依然报告「健康」,这就失去了它的意义。但也不要在健康检查里调模型 API,那样每 30 秒烧一次钱,而且模型 API 偶尔慢一下就会让容器被判定为不健康。
--proxy-headers 是因为服务会跑在 Caddy 后面。不加的话,应用拿到的客户端 IP 全是 Caddy 的内网地址,限流和审计日志里的 IP 就都错了。
在国内构建镜像,pip 和 docker pull 都可能很慢。uv 可以通过环境变量 UV_DEFAULT_INDEX 换成国内 PyPI 镜像(清华、阿里云都有),Docker Hub 的拉取速度问题在国内一直存在,各云厂商都提供镜像加速服务,买了服务器以后在控制台里能找到。
Compose:一条命令拉起整个系统
# agent-60-lab/compose.yaml
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: agent
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d agent"]
interval: 5s
retries: 10
restart: unless-stopped
# 不映射端口到宿主机:只有同一网络里的容器能访问
redis:
image: redis:7
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
restart: unless-stopped
migrate:
image: postgres:17
depends_on:
postgres: { condition: service_healthy }
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./w6/sql:/sql:ro
entrypoint: ["sh", "-c", "for f in /sql/*.sql; do echo \"== $$f\"; psql -h postgres -U postgres -d agent -v ON_ERROR_STOP=1 -f $$f || exit 1; done"]
restart: "no"
api:
build: .
env_file: .env
environment:
DATABASE_URL: postgresql://agent_writer:${AGENT_WRITER_PASSWORD}@postgres:5432/agent
REDIS_URL: redis://redis:6379
depends_on:
migrate: { condition: service_completed_successfully }
redis: { condition: service_started }
restart: unless-stopped
worker:
build: .
command: ["arq", "w6.app.jobs.WorkerSettings"]
env_file: .env
environment:
DATABASE_URL: postgresql://agent_writer:${AGENT_WRITER_PASSWORD}@postgres:5432/agent
REDIS_URL: redis://redis:6379
depends_on:
migrate: { condition: service_completed_successfully }
redis: { condition: service_started }
restart: unless-stopped
stop_grace_period: 60s # 给正在跑的任务留时间收尾(Day 37)
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddydata:/data
depends_on: [api]
restart: unless-stopped
volumes:
pgdata:
redisdata:
caddydata:
services:本机没有 Docker,这个文件未实跑,只用 PyYAML 解析过。实测解析出 6 个服务:postgres、redis、migrate、api、worker、caddy。image: pgvector/pgvector:pg17自带 pgvector 扩展的 Postgres 17。POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}${变量:?提示}表示变量没设或为空时,compose 直接报错退出并打印这句提示,不会用空密码把数据库起起来。- pgdata:/var/lib/postgresql/data命名卷,容器删掉重建数据还在。test: ["CMD-SHELL", "pg_isready -U postgres -d agent"]pg_isready 是 Postgres 自带的命令,能接受连接时返回 0。restart: unless-stopped崩了自动重启,除非你手动 stop。# 不映射端口到宿主机实测解析结果里 postgres 的 ports 是 None。command: ["redis-server", "--appendonly", "yes"]打开 AOF 持久化,Redis 重启后 arq 的任务队列不会丢。image: postgres:17migrate 只是要用它自带的 psql 客户端。postgres: { condition: service_healthy }等 postgres 的 healthcheck 通过才启动。- ./w6/sql:/sql:ro把 SQL 目录只读挂进去。entrypoint: ["sh", "-c", "for f in /sql/*.sql;实测解析出来的命令是for f in /sql/*.sql; do echo "== $$f"; psql ... -f $$f || exit 1; done。$$是 compose 的转义,交给 shell 时变成$f。/sql/*.sql展开时按文件名排序,001 到 006 的顺序就是执行顺序,所以 Day 40 的 005 不能引用 006 才建的表,编号也不能和前面的文件重复。|| exit 1任何一个文件失败就整体失败。restart: "no"必须加引号:YAML 1.1 里裸写的 no 会被解析成布尔值 false。build: .用当前目录的 Dockerfile 构建镜像。env_file: .env把 .env 里的变量(模型的 key 等)注入容器。DATABASE_URL: postgresql://agent_writer:${AGENT_WRITER_PASSWORD}@postgres:5432/agent主机名直接写服务名 postgres,compose 的内部网络自带 DNS。migrate: { condition: service_completed_successfully }实测 api 的 depends_on 解析出来是{'migrate': {'condition': 'service_completed_successfully'}, 'redis': {'condition': 'service_started'}}。command: ["arq", "w6.app.jobs.WorkerSettings"]和 api 用同一个镜像,只是换了启动命令。stop_grace_period: 60s停容器时先发 SIGTERM,等 60 秒还没退出才强杀。- "80:80"整个文件只有 caddy 映射了端口,实测解析结果是['80:80', '443:443']。- caddydata:/data证书存在这里,容器重建后不用重新申请。volumes:顶层的 volumes 声明上面用到的三个命名卷,由 Docker 管理存储位置。
migrate 是一个跑完就退出的一次性服务。它等 Postgres 健康后,按文件名顺序执行 w6/sql/ 下的全部 SQL 文件。api 和 worker 等它成功退出后才启动。这样数据库结构永远在应用启动之前就绪。我们这几天写的 SQL 都带了 IF NOT EXISTS,重复执行是安全的;Day 40 建角色的那个文件要改成幂等的写法(先判断角色是否存在),否则第二次部署时 CREATE ROLE 会报错退出。正式项目里迁移会交给 Alembic 这类工具,它会记录哪些迁移执行过,但概念是一样的。
$$f 是 Compose 文件里转义美元符号的写法,否则 Compose 会把 $f 当成它自己的变量去替换。
Postgres 和 Redis 都没有映射端口到宿主机。它们只在 Compose 创建的内部网络里可见,公网扫不到。我见过不少个人项目把 Postgres 的 5432 映射出去、密码还是 postgres,上线几小时就被勒索脚本清库。对外只开 80 和 443。
API 用的是 Day 40 建的 agent_writer 账号,不是超级用户。
Caddy:三行配置拿到 HTTPS
# agent-60-lab/deploy/Caddyfile
support.your-domain.com {
reverse_proxy api:8000
}
就这样。Caddy 会自动向 Let’s Encrypt 申请证书、自动续期、自动把 HTTP 跳转到 HTTPS。前提是这个域名已经解析到你服务器的公网 IP,而且 80 和 443 端口在云厂商的安全组里放行了。
Day 36 担心过代理缓冲会让流式输出变成一次性返回。Caddy 的文档写明:响应的 Content-Type 是 text/event-stream 时,它会忽略缓冲设置、每次写入后立即刷新给客户端。所以用 Caddy 做反向代理,SSE 不需要任何额外配置。如果你用 Nginx,则要记得 proxy_buffering off 和足够长的 proxy_read_timeout。
密钥管理
整个系统里需要保密的东西有:模型 API key、embedding API key、数据库的几个密码、Langfuse 的密钥、邮件服务的 key、Day 40 的 E2B key(如果用了)。规则只有三条:
不进 git。 .env 写进 .gitignore,另外提交一个 .env.example,列出所有变量名但值留空或写占位符。新人照着它填。如果你不小心把 key 提交过,删掉文件再提交是没用的,它还在 git 历史里。正确的做法是立刻去厂商后台作废这个 key,换一个新的。GitHub 会扫描公开仓库里的常见密钥格式并通知厂商,很多厂商会自动吊销,但不要指望这个。
不进镜像。 不要在 Dockerfile 里写 ENV LLM_API_KEY=...,也不要 COPY .env。镜像会被推到镜像仓库,任何能拉镜像的人都能用 docker history 或解包看到。.dockerignore 里排除 .env 就是为了这个。
能轮换。 假设每个 key 迟早会泄露,问自己:换一个 key 需要几步?在我们的设置里是:改服务器上的 .env,docker compose up -d 重启相关服务。如果你的回答是「要改代码重新构建」,说明 key 被写死在某处了。
服务器上的 .env 文件权限设成 600,只有部署用户能读。
到了公司的 K8s 环境,对应的东西是 Kubernetes Secret,或者更常见的云厂商密钥管理服务(阿里云 KMS、AWS Secrets Manager 之类),配合 External Secrets 这类组件同步进集群。概念是一样的:密钥和代码、镜像分开存放,运行时注入。
选一条部署路线
路线 A:国内轻量云
阿里云、腾讯云都有轻量应用服务器,新用户活动价很低。我 2026 年 10 月查到的第三方汇总里,2 核 2G 的活动价从几十元到一百多元一年不等,限新用户、限购一台,续费价格另算,一切以官网当时的页面为准。
配置建议至少 2 核 4G。我们的 Compose 里有 Postgres、Redis、API、worker、Caddy 五个常驻容器,2G 内存跑起来能用,但构建镜像时容易内存不足,而且没有余量给 Langfuse。Langfuse v3 自托管需要 web、worker、Postgres、ClickHouse、Redis、对象存储六个组件,第三方部署指南建议至少 4 核 8G。对个人项目来说,我的建议是 Langfuse 直接用它的云版免费额度,不要和应用挤在一台小机器上。
国内服务器有一道绕不开的坎:ICP 备案。用中国大陆的服务器、通过域名在 80/443 端口对外提供网站服务,需要先完成备案。备案要提交身份证明、填写网站信息,通常需要一到几周。没有备案的话,域名访问会被云厂商拦截。
几种应对办法:
- 先用 IP 加非标准端口访问做演示(比如
1.2.3.4:8080),不走域名,不需要备案。缺点是没有 HTTPS,浏览器会提示不安全,简历上放这样的链接观感一般。 - 买香港或海外地域的服务器。不需要备案,国内访问速度一般但能用,调 DeepSeek 等国内模型 API 也没问题。价格比大陆地域贵一些。
- 认真走一遍备案。如果你打算长期维护个人项目和博客,备案一次能用很久。
路线 B:海外平台或海外 VPS
Render 有免费的 Web Service,但空闲 15 分钟后会休眠,下次访问要冷启动几十秒到一分钟;免费的 Postgres 30 天后过期。最便宜的常驻实例大约每月 7 美元起。Fly.io 现在是纯按量计费,新账号只有短期试用,最小的常驻机器每月几美元。这些数字来自 2026 年的第三方价格汇总,两家官网的定价页链接放在后面,以官网为准。
这类平台的好处是不用管服务器,推代码就部署;坏处是我们这套多容器的 Compose 要拆成多个服务分别配置,而且托管数据库和 Redis 要另外付费。
对这门课的项目,我更推荐海外 VPS 加同一份 Compose 文件。Hetzner、Vultr、DigitalOcean 这些厂商每月 5 到 10 美元就有 2 核 4G 左右的机器,部署步骤和国内轻量云完全一样,也没有备案问题。
通用部署步骤
不管选哪条路线,拿到一台装好 Docker 的 Linux 服务器以后,步骤都是:
# 在服务器上,以一个非 root 的部署用户执行
git clone https://github.com/<you>/agent-60-lab.git && cd agent-60-lab
cp .env.example .env && chmod 600 .env && vim .env # 填密钥和密码
vim deploy/Caddyfile # 改成你的域名
docker compose up -d --build
docker compose ps # 全部 running / healthy,migrate 是 exited (0)
docker compose logs -f api worker # 看启动日志
curl -s https://support.your-domain.com/healthz
云厂商的安全组(防火墙)只放行 22、80、443。SSH 关掉密码登录,只用密钥。
别忘了备份
数据库里有会话、checkpoint、审计日志,丢了就真丢了。最简单的每日备份:
# 服务器 crontab:每天凌晨 3 点备份,保留 7 天
0 3 * * * cd /home/deploy/agent-60-lab && docker compose exec -T postgres pg_dump -U postgres agent | gzip > /home/deploy/backups/agent-$(date +\%F).sql.gz && find /home/deploy/backups -name 'agent-*.sql.gz' -mtime +7 -delete
备份放在同一台机器上只能防误操作,防不了机器坏掉。定期把备份文件同步到对象存储(阿里云 OSS、Cloudflare R2 之类)。另外,没恢复过的备份不算备份,至少恢复演练一次。
最小的 CI/CD
JD 里对 CI/CD 的要求很常见,有一条平台方向的岗位把 CI/CD 和基础设施即代码(IaC)直接列为硬门槛。对个人项目,一个够用的流水线是:推到 main 分支时,跑测试和评测、构建镜像推到镜像仓库、SSH 到服务器拉新镜像重启。
# agent-60-lab/.github/workflows/deploy.yml
name: test-and-deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked
- run: uv run pytest -q tests w6/evals/test_injection.py # Day 35 和 Day 39 的测试
env:
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_MODEL: ${{ vars.LLM_MODEL }}
deploy:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }},ghcr.io/${{ github.repository }}:latest
- name: Deploy over SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
HOST: ${{ secrets.DEPLOY_HOST }}
run: |
install -m 600 /dev/null key && echo "$SSH_KEY" > key
ssh -i key -o StrictHostKeyChecking=accept-new deploy@"$HOST" \
"cd agent-60-lab && git pull --ff-only && IMAGE_TAG=${{ github.sha }} docker compose pull api worker && IMAGE_TAG=${{ github.sha }} docker compose up -d"
name: test-and-deploy未实跑(需要 GitHub 仓库和服务器),只用 PyYAML 解析过。on:实测用 PyYAML 解析,这个键读出来是布尔值True而不是字符串"on":YAML 1.1 会把 on/off/yes/no 当成布尔。GitHub Actions 自己的解析器认得它,工作流本身没问题;但如果你用 Python 脚本读这个文件,要用w[True]去取。branches: [main]只有推到 main 才触发。runs-on: ubuntu-latest跑在 GitHub 提供的 Ubuntu 虚拟机上。- uses: actions/checkout@v4把仓库代码拉到虚拟机里。- uses: astral-sh/setup-uv@v6安装 uv。- run: uv run pytest -q tests w6/evals/test_injection.py实测解析出 test job 的四步依次是 checkout、setup-uv、uv sync --locked、这条 pytest。LLM_API_KEY: ${{ secrets.LLM_API_KEY }}secrets 加密存储,出现在日志里会被打码;下面的vars.是明文配置,只适合放模型名这类不敏感的值。needs: testtest job 失败就不部署。permissions:只给这个 job 需要的权限:实测解析出来是{'contents': 'read', 'packages': 'write'},读代码、往 ghcr 推镜像。password: ${{ secrets.GITHUB_TOKEN }}GitHub 每次运行自动发的临时 token,不用自己建。tags: ghcr.io/打两个标签:提交哈希(回滚时能精确指定版本)和 latest。install -m 600 /dev/null key && echo "$SSH_KEY" > key先建一个权限为 600 的空文件再写入私钥:ssh 拒绝使用别人也能读的私钥文件。ssh -i key -o StrictHostKeyChecking=accept-new第一次连接时自动记下服务器的指纹,以后指纹变了就拒绝连接,防止中间人。"cd agent-60-lab && git pull --ff-only--ff-only:服务器上的代码如果有本地改动或者分叉了就直接失败,不自动合并。
要让服务器用 CI 构建好的镜像,而不是在服务器上自己构建,compose.yaml 里的 api 和 worker 要加一行 image: ghcr.io/<you>/agent-60-lab:${IMAGE_TAG:-latest}。各个 action 的大版本号写的是我写这篇时的版本,用之前去它们的 GitHub 页面确认一下。
评测要不要放进 CI?Day 35 的结论是放,但要注意成本和时间:每次推代码都跑 100 条评测,一天推 20 次就是 2,000 次 Agent 运行。常见做法是 CI 里只跑确定性测试和一个 20 条左右的冒烟评测集,完整评测每天定时跑一次或者合并前手动触发。
这个流水线离公司里的做法还差很多:没有预发环境、没有灰度、回滚要手动、基础设施没有用 Terraform 这类工具描述。但面试时你能讲清楚「我的项目是怎么从提交到上线的」,并且知道下一步该补什么,已经够了。K8s 也一样,知道 Deployment、Service、Secret、HPA 这几个概念各自对应你 Compose 文件里的什么,比没碰过强很多。
自己跑开源模型:Ollama 和 vLLM
到目前为止我们都在调云端的模型 API。有些场景会要求模型跑在自己的机器上:数据绝对不能出内网的金融和政务客户;调用量极大、API 账单已经很吓人的公司;需要用自己微调过的模型。JD 里也常见「熟悉开源模型私有化部署」这样的描述。
Ollama:本机试一试
Ollama 是在个人电脑上跑开源模型最简单的工具。装好以后:
ollama pull qwen3:8b # 下载一个 80 亿参数的通义千问 3,4-bit 量化版本约 5 GB
ollama run qwen3:8b "用一句话解释什么是幂等"
关键在于,Ollama 提供了 OpenAI 兼容的接口。我们从 Day 3 开始就坚持用环境变量切换厂商,现在收到回报了:
# .env.local
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=ollama # Ollama 本地不校验 key,但 SDK 要求非空
LLM_MODEL=qwen3:8b
不改一行代码,整个工单 Agent 就跑在了本地模型上。然后你应该做的第一件事,是用 Day 35 的评测集跑一遍,和云端模型比较。一个 8B 的量化模型在分类这种简单任务上可能还行,在多步工具调用和起草回复上,通常会明显落后于云端的大模型。这个差距是多少,用你的评测集说话,不要凭印象。
在 Mac 上,Ollama 用的是统一内存。站长那台 2019 年的 Intel MacBook 跑 8B 模型会非常慢,能跑起来但每秒只有几个 token;Apple 芯片的 Mac 或者有独立显卡的 PC 体验会好很多。B 站上那个讲「普通电脑怎么本地跑大模型」的视频(资料区第二个)把显存、量化和模型大小的关系讲得很清楚,看完你就知道自己的电脑能跑多大的模型。
vLLM:生产环境的推理服务
Ollama 适合个人和开发。真要在服务器上给很多用户提供服务,业界主流是 vLLM。它的核心是 PagedAttention 和连续批处理,能让一张 GPU 同时服务很多请求,吞吐量比简单的推理脚本高很多。它同样提供 OpenAI 兼容接口:
# 需要 NVIDIA GPU 的 Linux 机器
uv pip install vllm
vllm serve Qwen/Qwen3-8B --port 8000
# 然后 LLM_BASE_URL=http://<gpu-host>:8000/v1,LLM_MODEL=Qwen/Qwen3-8B
工具调用要额外的启动参数(启用自动工具选择和对应模型的解析器),不同模型的参数不同,看 vLLM 文档里的 Tool Calling 一节。
什么时候值得自己部署
算一笔账。假设租一张能跑 8B 模型的 GPU 每小时 0.5 美元(这是假设值,不同平台和卡型差别很大,自己去查),对比 deepseek-flash 的输入价每百万 token 0.30 美元(标准时段,未命中缓存):
GPU 每小时成本 ÷ API 每 token 价格 = 打平所需的每小时 token 量
0.5 美元 ÷ (0.30 / 1,000,000) ≈ 167 万 token / 小时 ≈ 463 token / 秒,24 小时不停
这还只算了输入,而且没算缓存命中后 API 价格会降到五十分之一,没算运维的人力,没算 8B 模型和云端大模型之间的质量差距。对绝大多数中小规模的 Agent 应用,直接调 API 便宜得多。
自部署真正成立的理由通常不是省钱,而是数据不能出去,或者需要自己微调的模型,或者调用量大到了每天几十亿 token 的级别。面试时被问「为什么不自己部署模型」,用这个算式回答,比说「API 方便」有说服力得多。
动手练习
- 写好
Dockerfile和.dockerignore,本地docker build成功。改一行 Python 代码再构建,确认依赖层走了缓存(构建日志里显示CACHED)。 - 写好
compose.yaml,本地docker compose up -d(本地测试可以先把 Caddy 换成直接映射 api 的 8000 端口)。确认migrate正常退出、api 健康检查通过、Day 36 的测试页能用。 - 用
docker compose exec api whoami确认是非 root 用户;用docker history确认镜像里没有任何密钥。 - 选一条路线,把服务部署到公网,配上域名和 HTTPS。用手机 4G 网络打开测试页,跑通一张需要审批的工单,确认流式输出是逐字出现的。
- 配好每日备份,做一次恢复演练:新起一个 Postgres 容器,把备份导进去,查一下会话表的行数。
- 配好 GitHub Actions,推一个小改动,看着它自动部署上线。
- 用 Ollama 跑一个本地模型,把
LLM_BASE_URL指过去,用评测集跑 20 条,和云端模型对比。
验收标准:公网 HTTPS 地址可用,流式正常;仓库和镜像里没有密钥;做过一次备份恢复;CI 能自动部署;填好下面这张对比表。
| 模型 | 部署方式 | 评测通过率(20 条) | P50 首字延迟 | 单工单成本 |
|---|---|---|---|---|
| deepseek-flash | 云端 API | |||
| qwen3:8b | 本机 Ollama | (电费,约等于 0) |
名词解释
| 名词 | 意思 |
|---|---|
| 镜像分层缓存 | Dockerfile 每条指令生成一层,输入没变的层直接复用,所以要把变化少的步骤放前面 |
.dockerignore | 构建镜像时排除的文件列表,防止把本地环境和密钥打进镜像 |
| 健康检查 | 定期探测容器是否真正可用,失败时编排工具可以重启它 |
| 反向代理 | 位于应用前面接收公网请求并转发的服务,负责 HTTPS、限流等 |
| ICP 备案 | 在中国大陆用服务器通过域名提供网站服务前必须完成的登记 |
| CI/CD | 持续集成/持续部署:代码提交后自动测试、构建、发布 |
| IaC | 基础设施即代码,用 Terraform 等工具以代码形式描述服务器、网络等资源 |
| OpenAI 兼容接口 | 和 OpenAI API 请求格式一致的接口,Ollama、vLLM 和多数国内厂商都提供 |
| vLLM | 主流的开源大模型推理服务框架,以高吞吐著称 |
常见误区
把数据库端口映射到公网。 加上弱密码,几小时内就会被扫到。数据库只在内部网络可见。
密钥写在 Dockerfile 或代码里。 进了镜像和 git 历史就收不回来了。泄露后第一件事是去厂商后台作废。
健康检查只返回 200。 数据库挂了它还说健康。至少检查关键依赖,但别在里面调模型 API。
以为国内服务器买了就能用域名访问。 大陆地域要先备案,时间要算进计划里。
备份放在同一台机器上,而且从没恢复过。 机器坏了一起没,恢复时才发现备份是坏的。
为了「省钱」自己部署模型。 算一下打平所需的 token 量,再加上运维和质量差距,对大多数应用 API 更便宜。自部署的理由通常是数据和定制,不是钱。
学习资料
文档与文章
- uv · Using uv in Docker:官方 Docker 指南,分层缓存、钉版本、非 root 的写法都在里面。今天的 Dockerfile 就是照它改的。30 分钟。
- Docker Compose 文档:重点看
depends_on的condition、健康检查和环境变量插值。30 分钟。 - Caddy · reverse_proxy:看
flush_interval一段,确认 SSE 为什么不需要额外配置。10 分钟。 - pgvector 镜像:可用的标签列表。5 分钟。
- GitHub Actions 文档:第一次写工作流的话,看 Quickstart 和 Secrets 两节。30 分钟。
- Langfuse · Self-hosting:看一眼自托管的组件和资源要求,你就明白为什么建议个人项目用云版。10 分钟。
- Render 定价、Fly.io 定价:部署前自己核对当前价格。
- Ollama · OpenAI compatibility、vLLM · OpenAI-compatible server:两个工具的 OpenAI 兼容接口说明,包括支持哪些参数、工具调用怎么开。各 20 分钟。
B 站视频
其他经典视频
想系统学 Ollama 做应用的话,freeCodeCamp 有一门 3 小时的《Ollama Course: Build AI Apps Locally》(YouTube);B 站上大模型观察员的 Ollama 保姆级教程合集(BV1qfPQzBEYR)有 49 集,挑安装、API 调用和私有化部署几集看即可。
书籍
- Chip Huyen《AI Engineering》(O’Reilly, 2025)第 9 章「Inference Optimization」里讲推理服务的部分,以及第 10 章的架构总图:看完你会知道 vLLM 这类推理服务在整个 AI 应用架构里处在什么位置。1 小时。
- Jay Alammar、Maarten Grootendorst《Hands-On Large Language Models》(O’Reilly, 2024)第 7 章前后讲本地加载和量化模型的部分,适合想多了解开源模型的人。中文版《图解大模型》(人民邮电出版社)。40 分钟。
开源项目与代码
- ollama/ollama:README 里有支持的模型列表和 REST API 说明。
- vllm-project/vllm:看 README 了解它的定位和支持的硬件,不用读源码。
自测
- Dockerfile 里为什么先
COPY pyproject.toml uv.lock装依赖,再COPY . .?
答案
Docker 按指令分层缓存,某一层的输入没变就复用。依赖文件很少变,代码经常变。先复制依赖文件并安装,改代码时这一层仍然命中缓存,只重建复制代码那一层,构建从几分钟变成几秒。反过来写,每改一行代码都要重装全部依赖。
migrate服务为什么要独立出来,而不是在 API 启动时执行 SQL?
答案
API 和 worker 可能有多个副本同时启动,都去执行迁移会互相冲突。独立的一次性服务只执行一次,api 和 worker 通过 service_completed_successfully 条件等它成功后再启动,保证应用启动时数据库结构已就绪;迁移失败时应用不会带着错误的表结构启动。
- 你不小心把
.env推到了公开的 GitHub 仓库,第一时间该做什么?
答案
立刻去各个厂商后台作废泄露的 key 并生成新的,改服务器上的配置重启服务。然后再处理仓库:删除文件、必要时用工具清理 git 历史、把 .env 加进 .gitignore。顺序很重要,删文件不能让已经泄露的 key 失效,git 历史和各种镜像站可能早已抓取。
- 为什么建议个人项目的 Langfuse 用云版而不是和应用部署在同一台小服务器上?
答案
Langfuse v3 自托管包含 web、worker、Postgres、ClickHouse、Redis、对象存储等多个组件,第三方部署指南建议至少 4 核 8G,远超一台 2 核 4G 小机器的余量,会和应用抢资源。云版有免费额度,对个人项目的 trace 量足够,也省去了运维。
- (面试题)「你们为什么不自己部署开源模型?」请用数字回答。
答案
参考答法:算了打平点。假设一张 GPU 每小时 0.5 美元,对比 API 输入价每百万 token 0.3 美元,要每小时处理约 167 万输入 token,也就是 24 小时不间断约 460 token/秒才打平,这还没算缓存命中后 API 更便宜、没算运维人力、没算开源小模型和云端模型的质量差距(用评测集测过)。我们的量远达不到。自部署的真实理由是数据不能出域、需要微调模型或调用量极大,目前这些都不成立。架构上我们用 OpenAI 兼容协议,需要时改一个环境变量就能切到 vLLM。
- (面试题)你的 Agent 服务从提交代码到上线经过哪些步骤?还缺什么?
答案
现状:推到 main 触发 GitHub Actions,跑单元测试、注入测试和冒烟评测集;通过后构建镜像推到 GHCR,打上 commit sha 标签;SSH 到服务器拉新镜像,Compose 滚动重启,数据库迁移由一次性服务先执行;worker 有优雅退出时间,未完成任务依靠队列重投和幂等键。还缺:预发环境、灰度或蓝绿发布、自动回滚、基础设施用 Terraform 描述、完整评测定时跑并和上线门禁打通、迁移的版本管理(Alembic)。
明天预告
Day 42 · W6 复习 + 生产化清单。这一周我们把工单 Agent 从命令行带到了公网:流式服务、任务队列、成本控制、两天的安全加固、部署上线。明天把六天的内容收拢成一张上线前检查清单,把完整代码整理好,过一遍本周的面试题和踩过的坑,确认你的 Agent 真的经得起陌生人来用。
评论