把 Claude Agent SDK 的智能体放到服务器上:子进程、上限、记账、隔离和密钥
脚本在终端里能跑,放到服务器上给别人用还要多想几件事。本站实测了前半部分:每个会话是一个约 240 MB 的子进程,预算上限会超一点,3 秒的超时实际要 8 秒多才退出,花费怎么记才不重复,目录里的 CLAUDE.md 默认会被读进去。容器、代理和密钥部分整理自官方文档。
本站已实测,测试日期:2026-10-12。
本页目录(12 节)
这篇教程解决一个问题:智能体在你的终端里跑通了,要放到服务器上、让别人的请求来触发它,中间还隔着哪些事。
官方的托管文档开头有一句话说得很直接:托管 Agent SDK,和托管一个无状态的接口封装不是一回事。每个运行中的智能体都是一个带着本地状态的长进程。
这篇分两半:
- 前五节是本站实测的:它在机器上是什么样子、怎么给它套上限、花费怎么记、状态在哪、多个用户怎么隔开。
- 后三节整理自官方文档,本站没有实测:容器加固、密钥和代理、可观测性。测试机上没有装容器环境。每节开头都会再标一次。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-12 |
| 版本 | claude-agent-sdk 0.2.165,自带的 Claude Code 2.1.294 |
| 系统 | Ubuntu 24.04,Python 3.12.3,8 核 24 GB 内存 |
| 模型 | Opus 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 示例 | 前几篇的订单助手,只开放两个只读工具 |
如果你并不需要在自己的机器上跑智能体的循环,文档提到另一条路:Anthropic 托管的 Managed Agents,循环在他们那边跑,你的应用只收发事件。本文不涉及。
先看清它在机器上是什么样子
你的代码调用 query() 时,SDK 会另外启动一个 claude 命令行进程,通过标准输入输出和它通信。这个子进程拥有 shell、工作目录和磁盘上的会话文件。后面所有的部署决定,都是从这一点推出来的。
一个会话对应一个子进程。 同时跑 N 个会话,就是 N 个子进程。
本站用 ps 在运行期间采样了子进程的常驻内存:
| 测的是什么 | 实测结果 |
|---|---|
调用 query() 到子进程就绪 |
1.1 秒 |
| 一次问答(两次工具调用)的总用时 | 12.7 秒 |
| 一个会话的子进程内存峰值 | 243 MB |
| 三个会话同时跑 | 三个 claude 进程,各 231 到 233 MB,合计 698 MB,12.4 秒全部跑完 |
三个一起跑和单独跑一个用时差不多,它们互不等待。
内存要这样读:243 MB 是一个很短的会话,没有内置工具,只挂了订单助手那几个自定义工具。它是下限。文档给的起步配置是每个智能体 1 GiB 内存、5 GiB 磁盘、1 个 CPU,并且提醒内存会随着会话变长、工具用得多而增长。一台机器能放几个,文档的算法是:
每台机器的智能体数 = (机器内存 - 其他开销)÷ 单个会话的内存上限
单个会话的上限要自己测:按你预期的长度和工具负载跑一个有代表性的会话,记下峰值。
给每次运行套上三道上限
文档在“已知限制”里写着:会话自己不会超时。模型要是绕进一个循环,它会一直跑、一直花钱,直到有东西拦住它。
能拦的有三样:轮数、预算、时间。前两样是 SDK 的选项,第三样要自己加。下面是一个“一个任务一个进程”的写法,从环境变量拿任务,跑完写一行日志,按结果给退出码。容器里的入口脚本大致就长这样。
"""一个任务一个进程。"""
import asyncio, json, os, sys, time
from claude_agent_sdk import query, ResultMessage
async def main():
prompt = os.environ["TASK_PROMPT"]
start = time.time(); log = {"task": prompt[:40]}; result = None
try:
async with asyncio.timeout(float(os.environ.get("TIMEOUT_S", "120"))):
async for message in query(prompt=prompt, options=options(
max_turns=int(os.environ.get("MAX_TURNS", "8")),
max_budget_usd=float(os.environ.get("BUDGET_USD", "0.20")),
)):
if isinstance(message, ResultMessage):
result = message # 先把结果留住,不管它是成功还是出错
except TimeoutError:
log["error"] = "timeout"
except Exception as e:
log["error"] = f"{type(e).__name__}: {str(e)[:100]}"
if result is not None:
log.update(outcome=result.subtype, is_error=result.is_error, turns=result.num_turns,
cost_usd=round(result.total_cost_usd or 0, 4), session=result.session_id[:8])
else:
log["outcome"] = log.get("error", "no_result")
log["seconds"] = round(time.time() - start, 1)
print(json.dumps(log, ensure_ascii=False), file=sys.stderr)
ok = result is not None and result.subtype == "success" and not result.is_error and "error" not in log
if ok:
print(result.result)
return 0 if ok else 1
sys.exit(asyncio.run(main()))
options() 是上一篇开头的那个。asyncio.timeout 要 Python 3.11 以上。
同一个问题(“张伟最近一张订单到哪了?”),换四组上限各跑一次:
| 设置 | 结果 | 轮数 | 花费估算 | 实际用时 | 退出码 |
|---|---|---|---|---|---|
| 默认 | success |
3 | 0.0231 美元 | 13.8 秒 | 0 |
| 预算 0.005 美元 | error_max_budget_usd |
1 | 0.0098 美元 | 5.4 秒 | 1 |
| 最多 1 轮 | error_max_turns |
2 | 0.0161 美元 | 5.8 秒 | 1 |
| 超时 3 秒 | 没有结果消息 | - | - | 8.6 秒 | 1 |
每一行都有一件值得记住的事。
预算不是硬顶
预算设的是 0.005 美元,实际花了 0.0098 美元才停。SDK 是在一次模型响应回来之后才算账的:发现超了,停下。越线的那一次响应已经花出去了。
所以预算要设得比你能接受的数字低一些,留出一次响应的余量。响应越长、上下文越大,这个余量要留得越多。
出错时,结果和异常都会来
超预算和超轮数的那两次,日志里同时有两样东西:
{"error": "ResultError: Claude Code returned an error result: Reached maximum budget ($0.005) (exit code: 1)",
"outcome": "error_max_budget_usd", "is_error": true, "turns": 1, "cost_usd": 0.0098, …}
先来一条 subtype 是错误类型的 ResultMessage,上面有花费和会话 ID;循环结束时再抛一个 ResultError。
上面的代码把 result 放在 try 外面,就是为了这个:先收下结果,再接异常。如果只写 except,这次花了多少钱就丢了。
轮数上限拦住的时候,钱已经花了
最多 1 轮的那次,报告的轮数是 2,花了 0.0161 美元。它已经调用过一次工具,没等到给出回答就被截断了。用户什么也没得到,账单照旧。
轮数上限是保险丝,不是常规手段。设得宽一些,让正常的任务碰不到它。
超时 3 秒,实际 8 秒多才退出
这一条本站多测了几次:
设的是 3 秒,8.6 秒后拿到 TimeoutError,此前收到 1 条消息,剩下的子进程:[]
设的是 3 秒,8.5 秒后拿到 TimeoutError,此前收到 1 条消息,剩下的子进程:[]
设的是 6 秒,11.4 秒后拿到 TimeoutError,此前收到 3 条消息,剩下的子进程:[]
每次都多出 5 秒半左右。从 SDK 的源码看,原因是它收尾时先关掉子进程的输入,等最多 5 秒让子进程把会话文件写完,等不到才强行结束。代码注释里写着,不留这段时间,最后一条回答可能丢。
对部署来说有两点:
- 你的超时要比外层的超时短 6 秒以上。 比如网关 30 秒断开,这里最多设 24 秒。
- 超时之后没有残留的进程。 三次都查过,
claude子进程都退干净了。
超时的那次没有结果消息,所以日志里没有花费。钱照样花了。
把循环读完
并发测试的第一版里,本站在拿到 ResultMessage 后直接从循环里 return,程序退出时打出了一串 RuntimeError: Event loop is closed。改成把 async for 读到自然结束,就没有了。
ResultMessage 是最后一条消息,读完它循环自己会结束。记下结果,让循环走完。
记账:花了多少
先说这个数字是什么
total_cost_usd 是 SDK 在本地按一张内置的价格表估算出来的。文档原话的意思是:它是客户端的估算,不是权威的账单数据,不要拿它向最终用户收费,也不要拿它触发财务上的决定。
通过中转站用的话又隔了一层:中转站按自己的倍率扣费,和这个数字不是一回事。真实的花费看中转站后台。这个估算适合用来比较(这个任务比那个贵几倍)和设上限。
结果消息上的几个字段
一次运行的实测输出:
一条 AssistantMessage message_id=…1Httkf usage={"input_tokens": 335, "cache_creation_input_tokens": 747, "cache_read_input_tokens": 0, "output_tokens": 24}
一条 AssistantMessage message_id=…WxavPf usage={"input_tokens": 26, "cache_creation_input_tokens": 478, "cache_read_input_tokens": 747, "output_tokens": 24}
一条 AssistantMessage message_id=…yxPmBh usage={"input_tokens": 26, "cache_creation_input_tokens": 148, "cache_read_input_tokens": 1225, "output_tokens": 5}
一条 AssistantMessage message_id=…yxPmBh usage={"input_tokens": 26, "cache_creation_input_tokens": 148, "cache_read_input_tokens": 1225, "output_tokens": 5}
结束 success
result.usage: {"input_tokens": 387, "cache_creation_input_tokens": 1373, "cache_read_input_tokens": 1972, "output_tokens": 197}
result.model_usage: {"claude-opus-5-5": {"inputTokens": 391, "outputTokens": 217, "cacheReadInputTokens": 1972,
"cacheCreationInputTokens": 2551, "costUSD": 0.0225874, …}}
total_cost_usd: 0.0225874
对着文档,能读出四件事:
- 按步骤统计时要去重。 四条
AssistantMessage,只有三个不同的message_id。最后两条是同一次模型响应拆出来的,usage一模一样。直接相加会多算。 - 每一步的
output_tokens不能用。 三步分别是 24、24、5,加起来 53,而结果上是 197。文档说明每条消息上的输出数只是响应刚开始时的占位值,真实的输出数只在结果消息上。 usage和model_usage对不上,以后者为准。model_usage里的缓存写入是 2551,usage里是 1373。文档的解释是usage只算主循环;total_cost_usd和model_usage把主循环以外的请求也算进去。记账用total_cost_usd或model_usage。- 缓存在起作用。 第二步读了 747 个缓存的 token,第三步读了 1225 个。提示缓存是 SDK 自动做的,不用配置。这是通过中转站测到的,这家中转站把缓存相关的用量字段传了回来。
多次调用怎么加
分两种情况,规则相反:
| 情况 | 每个结果上的 total_cost_usd |
怎么算总数 |
|---|---|---|
各自独立的 query() |
只是这一次的 | 自己加起来 |
| 恢复同一个会话的多次调用 | 已经包含这个会话以前的花费 | 读最新的一个,不要加 |
第二种在上一篇里看到过:三轮对话结束时是 0.0266 美元,换一个进程恢复再问一句,结果上是 0.0282 美元。把两个数加起来就重复算了。
实际做法:日志里每一行都带上会话 ID,汇总时每个会话只取最后一条的花费,再把各个会话相加。
文档还说明了一点:max_budget_usd 只算这一次调用自己的花费,恢复会话时带回来的历史花费不计入。本站没有单独验证。
状态放在哪
文档列了三样默认放在本地磁盘上的东西。容器重启、缩容、换节点,它们都不会跟过去:
| 状态 | 默认位置 |
|---|---|
| 会话记录 | ~/.claude/projects/ |
CLAUDE.md 记忆文件 |
~/.claude/CLAUDE.md 和会话的工作目录 |
| 工作目录里的产出 | 会话的工作目录 |
会话记录是按工作目录分开放的。本站用三个不同的 cwd 跑过之后,~/.claude/projects/ 下面出现了三个目录:
-home-ccdemo-demo-sdk5-deploy
-home-ccdemo-demo-sdk5-deploy-tenant-a
-home-ccdemo-demo-sdk5-deploy-tenant-b
用户过几天回来要接着聊,而那时容器已经换了一个,怎么办?文档给了三条路,本站都没有实测:
- 会话存储适配器(
session_store)。 SDK 把会话记录同步一份到你自己的存储(对象存储、键值库、数据库),别的机器可以从那里恢复。它只同步会话记录,不管CLAUDE.md和工作目录里的文件。 - 自己搬文件。 把那个
.jsonl存起来,到新机器上放回~/.claude/projects/下面再恢复。 - 不依赖恢复。 把需要的结果(分析结论、做过的决定)存成你应用自己的数据,下次放进新会话的提示里。文档说这往往比搬会话文件更稳。
第三条值得优先考虑。配合上一篇的结构化结果,每次把要点按固定的形状存下来,下次带上,就不用管会话文件在哪台机器上。
容器活多久,文档归了四种模式:
| 模式 | 做法 | 适合 |
|---|---|---|
| 用完即弃 | 一个任务起一个容器,做完销毁 | 一次性的任务:修一个 bug、提取一张发票 |
| 长期运行 | 容器常驻,里面守着多个会话 | 聊天机器人、持续处理消息的助手 |
| 混合 | 容器用完即弃,会话记录存在外面,回来时再加载 | 隔很久才来一次的长对话 |
| 多智能体同容器 | 一个容器里跑几个互相配合的智能体 | 需要共享环境的协作 |
上面那个入口脚本就是第一种。第二种在 Python 里用 ClaudeSDKClient 把会话一直开着。
多个用户共用一台机器时怎么隔开
SDK 默认会从文件系统读设置和 CLAUDE.md。一个容器服务多个用户时,这些文件可能把一个用户的东西带进另一个用户的会话。
本站做了一个小实验。在 tenant-a 目录里放一个 CLAUDE.md:
# 甲店的口径
回答的最后必须另起一行,原样写上:【甲店专用口径】
然后用同一个问题、不同的工作目录和设置各跑一次:
| 工作目录 | 设置 | 回答 |
|---|---|---|
tenant-a |
默认 | 李娜有 1 张订单……【甲店专用口径】 |
tenant-a |
setting_sources=["user"] |
李娜有 1 张订单……(没有那一行) |
tenant-b(没有这个文件) |
默认 | 李娜有 1 张订单……(没有那一行) |
默认设置下,工作目录里的 CLAUDE.md 被读进去了,而且照做了。注意这个助手用的是自己写的系统提示,没有用 Claude Code 的预设提示,文件照样生效。
这有两层意思:
- 工作目录要按用户分开。两个用户共用一个目录,一个人的说明会影响另一个人的回答。
- 更要紧的是,谁能往工作目录里写文件,谁就能给智能体下指令。如果工作目录里放的是用户上传的内容,要么不加载项目设置,要么确保用户写不出一个
CLAUDE.md。
文档给的隔离清单:
async for message in query(
prompt=prompt,
options=ClaudeAgentOptions(
cwd=tenant_dir, # 每个用户自己的工作目录,每次都明确传
setting_sources=[], # 不读用户、项目、本地三层设置
env={
"CLAUDE_CONFIG_DIR": config_dir, # 每个用户自己的配置目录
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", # 自动记忆不受 setting_sources 控制,要单独关
},
),
):
...
另外在出口代理上给每个用户各自的规则(见后面的密钥一节)。Python 里 env 是叠加在继承来的环境变量上的;文档提醒 TypeScript 里它是整个替换,要自己把 process.env 展开进去。
有一处和第一篇的实测连得上:setting_sources=[] 连 ~/.claude/settings.json 也不读了。如果你的密钥和接口地址是写在那个文件里的,会得到“Not logged in”。服务器上这两样要通过环境变量给。本站的测试环境连接配置在用户设置里,所以上面的实验用的是 ["user"];[] 加环境变量的组合没有实测。
放进容器(整理自官方文档,本站没有实测)
依赖。 容器里要有 Python 3.10 以上(TypeScript 是 Node.js 18 以上)。两种 SDK 的安装包里大多已经带了 Claude Code 的可执行文件,不用另装。自带的版本和 SDK 的版本绑在一起,升级 SDK 就是升级它。
网络。 出站要能访问模型接口:直连是 api.anthropic.com,走中转站就是中转站的域名。用到的外部工具和 MCP 服务器也要能访问。入站由你的应用监听端口,子进程自己不监听网络。
隔离技术。 文档比较了四种:
| 技术 | 隔离强度 | 性能开销 | 复杂度 |
|---|---|---|---|
| 沙箱运行时(sandbox-runtime) | 好,默认配置就安全 | 很低 | 低 |
| 容器(Docker) | 看配置 | 低 | 中 |
| gVisor | 很强(配置正确时) | 中到高 | 中 |
| 虚拟机(Firecracker 等) | 很强(配置正确时) | 高 | 中到高 |
第一种和 Claude Code 自带的沙箱用的是同样的机制(Linux 上是 bubblewrap),本站在沙箱那篇里实测过它在 Ubuntu 上怎么开、拦得住什么。
加固过的容器。 文档给的一组 Docker 参数:
docker run \
--cap-drop ALL \
--security-opt no-new-privileges \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=100m \
--network none \
--memory 2g \
--pids-limit 100 \
--user 1000:1000 \
-v /path/to/code:/workspace:ro \
-v /var/run/proxy.sock:/var/run/proxy.sock:ro \
agent-image
| 参数 | 作用 |
|---|---|
--cap-drop ALL |
去掉可能用来提权的 Linux 能力 |
--security-opt no-new-privileges |
进程不能通过 setuid 程序拿到更高权限 |
--read-only |
根文件系统只读,智能体留不下改动 |
--tmpfs /tmp:… |
给一块可写的临时空间,容器停了就清掉 |
--network none |
不给任何网络接口 |
--memory、--pids-limit |
限制内存和进程数 |
--user 1000:1000 |
不用 root 跑 |
-v …:/workspace:ro |
代码只读挂载 |
-v …/proxy.sock:… |
挂进一个 Unix 套接字,通向容器外面的代理 |
最后两行是关键:容器没有网络,唯一的出口是那个套接字,另一头是你控制的代理。即使智能体被注入的内容带偏了,它也只能把请求发给代理,由代理决定哪些域名能去。
文档特别提醒:不要把 ~/.ssh、~/.aws、~/.config 这类目录挂进去。
密钥不要放在智能体够得着的地方(整理自官方文档,本站没有实测)
为什么要操心这个:智能体的行为会受它读到的内容影响。一个文件、一个网页、一段用户输入里夹着的指令,都可能让它做出你没打算让它做的事。文档把这叫提示注入。纵深防御的思路是:就算它被带偏了,它够得着的东西也有限。
代理注入。 文档推荐的做法是在智能体的边界外面跑一个代理:智能体发出不带密钥的请求,代理补上密钥再转发。智能体从头到尾见不到密钥。
- 模型接口:把
ANTHROPIC_BASE_URL指向你的代理。 - 其他流量:用
HTTP_PROXY和HTTPS_PROXY。但 HTTPS 流量在代理看来是一条加密隧道,看不到内容也加不了密钥;要改就得用能解密的代理,并在容器里装它的证书。
其他服务的凭据,用自定义工具隔开。 这一条和第二篇直接相关。那篇的订单助手设了 tools=[],没有 Bash,也没有读文件的工具,它能做的只有调用你写的三个函数。数据库怎么连、密码是什么,都在你的函数里,模型只看得到函数的返回值。
反过来,只要给了它 Bash 或读文件的工具,它所在环境里的环境变量和文件它就都读得到。沙箱那篇实测过:默认的沙箱不限制读,放在家目录里的假凭据文件被读了出来。
挂载代码前先清理。 文档列了只读挂载也会泄露凭据的常见文件:.env、~/.git-credentials、~/.aws/credentials、~/.docker/config.json、~/.kube/config、.npmrc、.pypirc、各种 *.pem 和 *.key。只复制需要的源文件进去。
入站认证不归智能体管。 文档的建议是把认证放在智能体容器前面的网关上,智能体收到的应该是已经验过身份的请求。
看得见它在干什么(整理自官方文档,本站没有实测)
一个会话会跨很多次模型请求和工具调用。没有遥测,你不知道哪个工具跑了、跑了多久、会话卡在哪。
SDK 从环境变量继承 OpenTelemetry 的配置,在容器一层设好就行:
CLAUDE_CODE_ENABLE_TELEMETRY=1
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.example.com:4318
文档说明:提示的文字和工具的参数默认不会导出,要另外打开。
不接遥测也有两样现成的东西可以先用起来,都是本站前面实测过的:上面入口脚本里的那一行日志(会话、结果、轮数、花费、用时),和第三篇里用 PostToolUse hook 写的审计日志(每次工具调用一行)。
上线前过一遍
- 每次运行都设了
max_turns和max_budget_usd,外面再包一层自己的超时。 - 自己的超时比上游(网关、任务队列)的超时短 6 秒以上。
- 结果消息先收下再处理异常;
success之外的结果也记了花费。 - 日志每行带会话 ID;恢复的会话只取最后一条的花费。
- 知道
total_cost_usd是估算,对账看的是服务商后台。 - 按有代表性的会话测过内存峰值,算过一台机器放几个。
- 每个用户有自己的
cwd;用户能写文件的目录不加载项目设置。 - 想清楚了用户回来时会话从哪来:存储适配器、搬文件,还是不依赖恢复。
- 只开放任务需要的工具。不需要 Bash 和读文件就不给。
- 密钥和接口地址通过环境变量或代理给,没有打进镜像,也没有挂载带凭据的目录。
- 容器不用 root,限制了内存和进程数,出站只能到需要的域名。
这一趟花了多少
十几次运行(并发的两次各是三个会话),合计约 0.3 美元:单次问答 0.02 美元上下,三个会话同时跑一次约 0.05 美元。几次超时的运行没有结果消息,是按同类运行估的。这些是 SDK 按官方价格估算的数字,不是中转站实际扣的金额。
这次实测没有覆盖到的
- 没有真的装进容器。 测试机上没有 Docker。上面的容器参数、gVisor、虚拟机、Unix 套接字代理都只是文档的内容。
- 没有搭代理,密钥注入、域名白名单、TLS 解密都没有试。
- 会话存储适配器和跨机器恢复没有试。
- OpenTelemetry 没有接。
- 没有包成 HTTP 服务,也没有做压力测试。并发只试了三个,每个都很短。
- 内存只测了短会话。 长会话、带内置工具、带子代理的会话会用多少,没有测。
setting_sources=[]加环境变量给密钥的组合没有实测,只在第一篇里见过不给密钥时的报错。自动记忆、CLAUDE_CONFIG_DIR的隔离效果也没有逐项验证。- 超时多出的 5 秒半只在这个版本上测过,原因是读源码得出的,以后的版本可能会变。
- TypeScript 没有实测。
这个系列到这里
五篇连起来是一条线:
- 第一个智能体:装好 SDK,看懂消息流里有什么。
- 自定义工具:让它能查你的数据、调你的接口,并且只能做这些。
- 权限、审批和 hooks:有后果的操作,由你的程序决定放不放行。
- 会话、逐字输出和结构化结果:记住前面聊过的,边出边显示,返回程序能用的数据。
- 这一篇:放到服务器上。
每一篇里最要紧的都是同一件事:模型负责判断怎么做,边界由你的代码来定。工具开放哪些、哪些操作要批准、最多花多少钱、够得着哪些文件和密钥,这些都不该指望模型自己把握。