Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 14 期:放到线上给别人用——容器、密钥、成本

第 13 期结束时,这个客服 agent 已经是一个能跑在两个进程上、状态落在 Postgres 里的 HTTP 服务。但它还在你的笔记本上。别人要用,得有一个 公网地址,笔记本合上也还在。这一期就做这一步,用 Render 的免费层 把第 13 期那个服务原样部上去,拿到一个 https:// 地址,然后把第 12、 13 期本机验证过的每一条请求对着公网地址重跑一遍。

代码这一期几乎没动。动的全在代码外面:一份 Dockerfile、一组环境 变量、一个托管的 Postgres。真机部署撞上了三个事先没料到的问题,其中 一件逼着改了两行业务代码——这一期的正文有一半在讲那三件。

敲进去

第 14 期的代码在 code/ep14/app.py 的路由跟第 13 期一字不改。 新增 Dockerfilecode/.dockerignorecommon/settings.py 加一个 开关,retrieval.py/tools.py 各改一处(后面说为什么)。

Dockerfile:把进程打进容器

FROM python:3.12-slim

RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.9.7 /uv /uvx /usr/local/bin/

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev \
    --no-install-package torch \
    --no-install-package triton \
    --no-install-package nvidia-cudnn-cu13 \
    # ……一共 20 个 nvidia-*/cuda-*/triton 包,完整名单见仓库
    --no-install-package nvidia-nvtx
RUN uv pip install --python .venv/bin/python torch==2.14.0 \
    --index-url https://download.pytorch.org/whl/cpu

COPY common ./common
COPY ep14 ./ep14

ENV PATH="/app/.venv/bin:$PATH"
CMD ["sh", "-c", "uvicorn ep14.app:app --host 0.0.0.0 --port ${PORT:-8000}"]

四个地方值得停一下。

构建上下文是 code/ 这一层。 全书共用一份 pyproject.toml/uv.lock,装依赖只能在那一层做,所以构建命令是 docker build -f ep14/Dockerfile .,在 code/ 目录下执行。 .dockerignore.venv/*.sqlite.env 挡在外面——本机的虚拟 环境几个 GB,不挡的话每次构建都先把它整个传给 Docker。

uvx 也要进镜像。 第 7 期接的 MCP 时间服务器是靠 uvx mcp-server-time 在启动时现拉现跑的,运行时容器里没有 uvx 这个 工具就接不上。uv 的官方镜像一次给两个文件,/uv/uvx,两个都拷。

torch 那一段。 uv.lock 里的 torch 是从 PyPI 默认索引解出来的, 在 Linux 上它会连带装一整套 GPU 运行库:nvidia-cudnn-cu13nvidia-cublasnvidia-nccl-cu13triton……一共 20 个包,好几个 GB。这个容器只用 CPU 做第 8 期那个 FAQ 检索的 embedding,GPU 库一个 字节都用不上。做法是 uv sync 时把 torch 和它这 20 个依赖整组跳过, 再单独从 PyTorch 官方的 CPU 索引装同版本的 torch——Render 的构建日志 里这一步下载 187MB、3.5 秒。pyproject.toml/uv.lock 本身不动,那是 全书共用的,只在这一期的镜像构建步骤里做局部替换。

端口从 $PORT 读。 Render 把容器该监听的端口通过环境变量 PORT 注入(实际给的是 10000)。写死 8000 的话服务起来了、 平台探测不到端口,部署直接判失败。${PORT:-8000} 让本机 docker run 不设这个变量也能跑。

开关:免费实例装不下 embedding 模型

# common/settings.py
RETRIEVAL_ENABLED = os.environ.get("RETRIEVAL_ENABLED", "1") not in ("0", "false", "no")
# ep14/tools.py
TOOLS = [*BASE_TOOLS, load_skill, check_orders]
if settings.RETRIEVAL_ENABLED:
    TOOLS.append(search_faq)

retrieval.pyimport numpyfrom sentence_transformers import SentenceTransformer 从模块顶部挪进了用到它们的函数里。工具不在 TOOLS 表里,模型就看不见它、不会调它,那几百 MB 的 import 也就 永远不会发生。为什么要这么做,“你应该看到什么“里有实测数字。

Render 上要建两样

一个 Web Service,一个 Postgres,都选 free 方案,同一个区域(oregon)。 Web Service 指向 GitHub 仓库 Leihb/langgraph-in-action,根目录 code,Dockerfile 路径 ./ep14/Dockerfile,健康检查 /health。 环境变量五个:

MODEL_BASE_URL   https://api.deepseek.com/v1
MODEL_API_KEY    (DeepSeek 的 key)
MODEL_NAME       deepseek-v4-flash
API_KEY          (这个服务自己的密钥,随机生成 32 位,调用方拿它调)
POSTGRES_URL     (Render 给的内部连接串,Web Service 和数据库同区域才能用)
RETRIEVAL_ENABLED  0

第 12 期强调过的那句这里再说一遍:MODEL_API_KEY 是这个服务去调 模型的身份,API_KEY 是别人调这个服务的身份,两把钥匙。密钥只存在 Render 的环境变量里,仓库里一个字符都没有——.env.gitignore.dockerignore 里都挡着。

这一期建资源用的是 Render 的 HTTP API(api.render.com/v1),没走 网页控制台:Render CLI 1.1.2 版只能管已有的服务,建不了新的。用控制台 点出来的资源一模一样,读者照着上面那张表在网页上填就行。

跑起来

本机先验一遍镜像能起:

cd code
docker build -f ep14/Dockerfile -t langgraph-ep14 .
docker run --rm -p 8000:8000 -m 512m -e API_KEY=test -e RETRIEVAL_ENABLED=0 langgraph-ep14
curl http://localhost:8000/health

-m 512m 是故意加的:Render 免费实例就是 512MB,本机先用同样的 上限跑一遍,能省掉一次线上失败。

线上部署好之后,对着公网地址跑第 12、13 期的那些请求:

BASE=https://langgraph-ep14.onrender.com
curl $BASE/health
curl -N -X POST $BASE/chat -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"wang","thread_id":"t1","message":"我的订单 KL-778 能改期吗"}'

你应该看到什么

第一次部署:构建成功,启动被杀

镜像在 Render 上构建顺利,日志最后几行是这样的:

==> Deploying...
==> No open ports detected, continuing to scan...
==> No open ports detected, continuing to scan...
INFO:     Started server process [7]
INFO:     Waiting for application startup.
==> Out of memory (used over 512Mi)

Deploying 到 uvicorn 打出 Started server process 用了 3 分钟, 然后进程还没走完 lifespan 就被平台杀了。免费实例 512MB 内存。

回到本机,用 -m 512m 跑同一个镜像,复现了:容器在健康检查通过之前 就被 Killed。去掉内存上限再跑,量出来空转的 uvicorn 进程占 551MB。 再用一个干净的 Python 解释器逐层 import,看内存是在哪一步涨上去的:

bare python:                 8 MB
+fastapi/langgraph/psycopg: 102 MB
+import torch:              298 MB  (0.7s)
+import sentence_tf:        485 MB  (2.5s)
+load bge-small model:      521 MB  (80.6s)

整个业务栈——FastAPI、LangGraph、Postgres 驱动、MCP 客户端——加起来 100MB 出头。第 8 期那个本地 embedding 方案在还没处理任何请求的时候 就多占了近 400MB,因为 retrieval.py 顶部那两行 import 在服务启动时 就执行了。

这就是上面那个开关的来由。把 import 挪进函数、加开关关掉工具,同一个 镜像、同样的 -m 512m

health after ~50s: {"status":"ok"}
container mem: 165.6MiB / 512MiB
tools: ['get_order', 'get_policy', 'cancel_order', 'remember_note', 'load_skill', 'check_orders']
torch imported at startup? False

165MB,起来了。代价是线上这一份没有 search_faq,客人问行李、发票 这类通用问题它只能按常识答,第 8 期讲的检索这条线在免费实例上断了。 要接回去有两条路:买内存更大的实例,或者把 embedding 从进程里挪出去 换成一个远端的 embedding 接口——两条都要花钱,这一期选了不花。

第二次部署:live

改完推上去再部署一次,构建走缓存只用了 19 秒,容器从 Started server processApplication startup complete 21 秒——其中 MCP 那一步 Installed 32 packages in 4.10suvx 在容器里现装 mcp-server-time。 启动日志里那行 [storage] Postgres: dpg-.../langgraph_ep14_db 确认接 的是 Render 的托管库。Render 自己的内存监控上这个实例峰值 437MB (平台的统计口径包含文件缓存,比容器里量的进程数字大)。

然后对公网地址跑第 12、13 期的那套请求,七条全过。挑几条贴原样:

### 1. health
{"status":"ok"}  http=200 time=0.606513s
### 2. 无密钥 / 错密钥
  no key    -> 401
  wrong key -> 401
### 3. /chat 流式:查订单
  data: {"type": "tool_call", "name": "get_order", "args": {"order_id": "KL-778"}}
  data: {"type": "tool_call", "name": "get_policy", "args": {"product_id": "SKU-1001", "topic": "reschedule"}}
  data: {"type": "answer", "content": "您的订单 KL-778(东京迪士尼一日票,出行日 9月7日)**可以免费改期**。..."}
  data: {"type": "done"}
### 4. 同 thread 第二问
  data: {"type": "tool_call", "name": "get_policy", "args": {"product_id": "SKU-1001", "topic": "refund"}}
  data: {"type": "answer", "content": "您的订单 KL-778(出行日 9月7日)**不支持退款**。..."}
### 5. interrupt:取消订单
  data: {"type": "tool_call", "name": "cancel_order", "args": {"order_id": "KL-901"}}
  data: {"type": "interrupt", "payload": {"action": "cancel_order", "order_id": "KL-901", "customer": "陈先生", "product": "东京迪士尼一日票"}}
### 6. 长任务
{"thread_id":"live-...-c","status":"submitted"}
  poll 1: done | 三个订单都能改期:- **KL-778**:出行日前3天可免费改期一次。...
### 7. 跨会话记忆:换一个 thread 问
  data: {"type": "answer", "content": "记得的~您以后出行都会带轮椅,需要无障碍安排。..."}

第 4 条那句“那退款政策呢“没带订单号,模型直接拿上一轮的 KL-778 去查 refund——checkpointer 在公网上和本机一样工作。第 7 条是第 6 期的 Store:先在一个 thread 里说“以后出行都带轮椅“,换一个全新的 thread 问,它记得。

重启容器,记忆还在

第 13 期在本机做过“杀进程、换磁盘、重启“的对照实验,这一期在真实 平台上做同一个实验:调 Render 的重启接口,事件记录里出现 server_restarted,健康检查恢复后,再开一个全新的 thread 问同一个 用户:

data: {"type": "answer", "content": "您之前提过:以后出行都会带轮椅,需要无障碍安排。..."}

Render 免费层的 Web Service 没有持久磁盘,容器重启后本地文件系统 是新的——所以这条记忆不可能在容器里,只能在 Postgres 里。第 13 期 说“存储要活在进程外面“时还是一个选择,到了免费实例上它是唯一的 选项:SQLite 那条路在这里根本不存在。

发生了什么

部署的大部分工作在代码外面。 这一期改的业务代码只有两处,加起来 不到十行;花时间的全是别的:镜像里该有什么、不该有什么,端口从哪里 读,密钥放哪,内存够不够,构建机器和你的笔记本是不是同一种 CPU。 第 12、13 期把状态挪出进程、把配置挪进环境变量,就是在为这一步铺路—— 如果 checkpointer 还在 SQLite 文件里,这一期光是解释“为什么重启后 记忆没了“就要占一半篇幅。

镜像在哪里构建,比在哪里运行更容易踩坑。 这台开发机是 Apple 芯片,docker build 默认产出 arm64 镜像;Render 的机器是 amd64, arm64 镜像推上去起不来。本机又没装跨架构构建的 buildx 组件。最省事 的解法是根本不从本机推镜像,让 Render 从 GitHub 仓库拉代码、在它自己 的 amd64 机器上构建——这也是读者最可能走的那条路。代价是用公开 仓库地址建的服务收不到 GitHub 的推送通知,git push 之后要手动 触发一次部署(网页上点一下,或者调一次 API);连上 GitHub 账号才有 自动部署。

512MB 是一条真实的线,它逼出了一个架构层面的决定。 本地跑的 时候没人在意 import 一个库要多少内存,笔记本有 16GB。免费实例把 这条线画在 512MB,第 8 期那个“不调外部 API、纯本地跑“的 embedding 方案在这条线前面直接倒下——它省的是网络依赖和 key,花的是内存, 本机看不见这笔账,上线第一天就得付。这一期的选择是把它关掉,正文 把代价说清楚了;换成买大实例或换远端 embedding 都成立,只是要花钱, 而这一期的题目里有“成本“两个字。

懒加载这两行代码值得单独说。import torch 从模块顶部挪进 函数,对本机跑没有任何影响,对线上则是“能不能起来“的区别。这种 改动在功能测试里永远测不出来,只有真的部到一台内存受限的机器上 才会显形——这也是这一期坚持要真机部署、没有停在纸面讲容器化道理的 原因。

免费层的三条规则都对应着一个第 13 期讲过的机制。 没有持久磁盘 → 存储必须在进程外(Postgres)。闲置 15 分钟休眠、有请求再唤醒 → 第 13 期的 /chat/submit 起的后台任务会跟着进程一起消失,/status 会停在最后一个 checkpoint 上,这一期没修,常见问题里有说。免费 Postgres 建库 30 天后到期 → 这份数据库是演示用的,到期就没了,真给 别人用要么付费要么自己接一个别处的 Postgres。这一期的服务和数据库 在这本书发布后会被删掉,读者跟着做要自己建一份。

常见问题

Render 免费层真的不要钱吗? 方案本身是免费的,这一期从建资源到 验证完成没有产生费用。但建资源时 Render 要求账户先绑一张卡(API 直接返回 “Payment information is required”),跟它文档里“免费层不需要 支付信息“的说法对不上——以实际行为为准。绑卡之后只要一直用 free 方案就不扣钱,超出免费额度的结果是暂停服务,账单不会自动涨。

为什么选 Render? 这一期前后看过三个:Fly.io 绑卡即结束免费试用、 之后按秒计费;Vercel 是无服务器函数模型,第 13 期的后台任务、SSE 长连接、MCP 子进程常驻三样都跟它的运行方式冲突,要重做架构;Render 的免费 Web Service 是常驻容器,第 13 期的代码一行不改就能跑。选它 的理由只有“代码不用改、方案免费“两条,没有别的。

闲置休眠会怎样? 15 分钟没有请求,Render 会把免费实例停掉,下一个 请求进来再拉起。实测:让服务闲置 17 分钟后打第一个 /health,等了 92.5 秒才回 200;紧接着第二个请求 0.57 秒。这 92 秒里有容器重新拉起、 uvx 重装 MCP 服务器、连 Postgres 三段。对用的人的体感是:一上午没人用, 第一个人发的第一句话要等一分半。

/chat/submit 提交的任务,实例休眠或重启了会怎样? 跟第 13 期 常见问题说的一样:后台 asyncio.Task 随进程消失,/status 停在 最后一个 checkpoint,不会自己变成 done。免费层的休眠让这件事从 “偶尔发生“变成“每天都会发生”,真要用这条路径得补上第 13 期加分练习 说的那种检测。

RETRIEVAL_ENABLED 开回来会怎样? 服务能起(懒加载之后启动 时不 import torch),第一个触发 search_faq 的请求会去加载模型, 进程内存越过 512MB,实例被杀,正在处理的请求一起没了。比“起不来“ 更糟——一个平时正常、问到某类问题就崩的服务。所以免费实例上这个 开关必须关着。

线上还是第 12 期那一把共享密钥? 是。给别人用的服务,正经做法是每个 调用方一把独立的 key,能单独吊销、分别记账、按 key 限流——这一期没做, 因为它跟“部署“这件事无关,是服务自己的功能。要加的话,在 check_auth 里把“跟一个常量比对“换成“查一张 key 表“(存 Postgres 里,跟 checkpointer 同一个库就行),路由一行不用动。这一期的地址只发给了写这本书的人自己, 共享密钥够用;发给第二个人之前先做这一步。

日志和监控在哪看? Render 控制台里有实时日志和内存/CPU 曲线, 这一期的 OOM 就是在日志里看到的、内存阶梯是在本机量的。第 11 期 的 Langfuse 这一期没接上去——本机那个 Langfuse 实例公网访问不到, LANGFUSE_* 三个变量没设,settings.LANGFUSE_ENABLED 是 False, 上报代码不会执行。真要接,把 Langfuse 也部到公网上或者用它的云服务。

加分练习

  1. LANGFUSE_* 三个变量配上(Langfuse Cloud 有免费额度),让 线上这份服务的每一次对话都能在第 11 期那个界面里看到 trace。
  2. 连上 GitHub 账号,让 git push 自动触发部署;然后故意推一个 起不来的版本(比如把 $PORT 写死成 8000),看 Render 怎么处理 一次失败的部署、旧版本还在不在。
  3. 用远端 embedding 接口(任何兼容 OpenAI embeddings 协议的服务) 替掉 retrieval.py 里的本地模型,量一下进程内存和首次检索延迟, 把 RETRIEVAL_ENABLED 开回来。
  4. 免费 Postgres 30 天后到期。在到期前用 pg_dump 导出一次,再建 一个新库导入,改 POSTGRES_URL,验证记忆是否完整迁移——这是 “存储和服务分开“在运维上的另一半。