Claude Agent SDK 补遗:系统提示从哪起步、流式输入、把会话存到自己的存储、常见报错
Agent SDK 系列前八篇没讲透的四件事:系统提示的四种起点和 CLAUDE.md 的关系,用流式输入在一个会话里连发消息和打断,用 20 行代码把会话记录存到自己的存储,以及几种常见报错各是什么原因。全部在服务器上用 Python 实测,顺带踩到一个走中转站才有的“未登录”。
本站已实测,测试日期:2026-10-13。
这一篇补四件事,都是把智能体从“能跑”做到“能上线”时会碰到的:
- 系统提示该用现成的、在现成的后面加一段,还是自己写?
- 怎么在一个会话里连着发消息,中途还能打断?
- 服务有好几台机器,会话记录存在哪,另一台怎么接着聊?
- 报错了,是哪一层的问题?
没用过 Agent SDK 的话,先看《Claude Agent SDK 入门》;会话和恢复的基本用法在《会话和流式输出》。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-13 |
| 版本 | Python claude-agent-sdk 0.2.165,Claude Code 2.1.296,Linux 服务器 |
| 模型 | Sonnet 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
先说实测的结论:
- 系统提示的四种起点表现和文档一致。CLAUDE.md 不在系统提示里,自己写系统提示时它照样生效。
- 恢复会话时换一段系统提示,不生效。 要改规矩,把新要求写进下一条消息。
- 流式输入:一个会话里连发、用生成器一次交两条、中途打断,都跑通了。
- 会话存储:自带的内存存储和自己写的 20 行文件存储,都能存、都能从存储恢复,后者通过了 SDK 自带的一致性检查。
- 走中转站的一个坑:
setting_sources=[]会让它连用户设置里的凭据也不读,报“未登录”。
一、系统提示从哪起步
四种起点
| 你在做什么 | 用什么 | 得到什么 |
|---|---|---|
| 命令行或 IDE 那样的编程工具,有人看着、随时纠正 | claude_code 预设 |
Claude Code 自己的系统提示,带工具用法和安全规则 |
| 同上,再加自己的规矩:编码规范、输出格式、领域背景 | 预设加 append |
上面的全部,你的话接在后面。什么都不删,风险最小 |
| 界面、身份或权限模型不一样的智能体,或者不是写代码的 | 自己写一段 | 只有你写的。工具怎么用、哪些事不能做,要你自己补上 |
| 一个很薄的调工具的循环,行为全写在用户消息里 | 不设 | 最小的默认提示:只管工具调用,别的没有 |
要留意第四行:不设 system_prompt 时,SDK 用的是一个最小的提示,不含预设里的安全说明。 这和 claude -p 不一样,命令行默认用的是完整的那一份。
写法:
from claude_agent_sdk import ClaudeAgentOptions
PRESET = {"type": "preset", "preset": "claude_code"}
ClaudeAgentOptions() # 不设
ClaudeAgentOptions(system_prompt=PRESET) # 预设
ClaudeAgentOptions(system_prompt={**PRESET, "append": "回答用中文。"}) # 预设加一段
ClaudeAgentOptions(system_prompt="你是“小账”,只回答记账问题。") # 自己写
Python 里很长的提示别直接传字符串,用 {"type": "file", "path": "..."} 从文件读。字符串是当成一个命令行参数传给子进程的,太长会超过系统的限制。
实测
同一句“用一句话回答:你是谁?”:
| 系统提示 | 输入 token | 回答 |
|---|---|---|
| 不设 | 24,051 | 我是 Claude,由 Anthropic 开发的 AI 助手,当前运行在 Claude Agent SDK 中…… |
| 预设 | 25,931 | 我是 Claude Sonnet 5.5……在这里帮你完成软件工程相关的任务。 |
| 预设,加“最后一个词必须是 OVER” | 25,965 | 我是 Claude Code,由 Anthropic 开发的命令行软件工程助手……OVER |
| 自己写的“小账” | 24,086 | 我是小账,一个专门回答记账问题的助手。 |
给“小账”一个它不该管的问题(写冒泡排序),回答是“这不归我管”。
两点观察:
- 不设和预设只差不到两千个 token。 剩下的两万四千个不管用哪种都在,本站推测主要是工具的定义。想让请求变小,光换系统提示没有用。
- 身份完全由系统提示决定:预设下它说自己是做软件工程的,换成自己写的就只认“小账”。
CLAUDE.md 走的是另一条路
append 和自己写的提示改的是系统提示本身。CLAUDE.md 不是:SDK 读到它之后,把内容作为项目上下文放进对话里。所以不管系统提示用哪种,它都生效,前提是 setting_sources 里有对应的来源。
实测:目录里放一个 CLAUDE.md,要求“每个回答的最后加上【来自 CLAUDE.md】”,系统提示用自己写的“小账”:
setting_sources |
回答的结尾 |
|---|---|
["user", "project"] |
……月底对账会轻松很多。【来自 CLAUDE.md】 |
["user"] |
没有那六个字 |
Claude Code 还会往对话里加别的东西,同样不在系统提示里:可用的 skill 和子代理的清单、hook 返回的上下文、任务列表的提醒、“某个文件在磁盘上变了”的提示、提交信息里的署名。文档管它们叫系统提醒。
自己写系统提示时,加一句话说明什么是系统提醒。 预设里有这句,你的字符串把整个预设换掉了,不说明的话,模型不知道对话里那些 CLAUDE.md 内容和 hook 输出是哪来的。
要关掉某一样,因为你自己的提示里已经有了:
| 要关的 | 怎么关 |
|---|---|
| 内置的提交和 PR 说明、git 状态快照 | 设置 includeGitInstructions 为 false |
| 提交信息里的署名和 PR 的页脚 | 设置 attribution.commit 和 attribution.pr,空字符串就是去掉 |
| 用户或项目的设置,连同它的 CLAUDE.md | setting_sources 里不写那一项 |
| 全部 CLAUDE.md | 环境变量 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 |
| 任务提醒、文件变化的提示、skill 清单 | 环境变量 CLAUDE_CODE_DISABLE_ATTACHMENTS=1 |
设置项通过选项里的 settings 传一段 JSON,环境变量通过 env 传。
恢复会话时换系统提示,不生效
文档说:回到一个会话时传不一样的 append 或提示,下一轮模型看不到。Claude Code 在会话的第一个请求上记下系统提示,之后一直用那一份,直到会话被压缩。
实测三步:
| 做了什么 | 回答 |
|---|---|
新会话,append 要求以 OVER 结尾 |
好 OVER |
恢复这个会话,append 改成以 DONE 结尾 |
行 OVER |
再恢复,不改 append,把“从现在起以 DONE 结尾”写进消息 |
可 DONE |
所以运行中要改规矩,比如用户把智能体切到只读,把新的要求放进对话:写进下一条用户消息,或者从 UserPromptSubmit 的 hook 里用 additionalContext 加进去,写成一句陈述,比如“工作区现在是只读的”。
很多用户共用一份缓存
同样的预设加同样的 append,两个会话默认也共用不了提示缓存:预设把每个用户的自动记忆目录写进了系统提示,位置在你的 append 前面。加上 "exclude_dynamic_sections": True,这些因人而异的内容会挪到第一条用户消息里,系统提示就完全一样了。代价是挪出去的那些话分量稍轻一点。
实测加上之后请求照常,输入 25,868 个 token,和不加时差不多。有没有真的多命中缓存,要两个不同的用户才测得出,本站没有测。
二、流式输入:一个会话里连着发
两种输入方式
- 单条消息:
query(prompt="一句话"),问一次答一次,要接着聊就靠resume。简单,适合一次性的任务。 - 流式输入:智能体作为一个一直开着的进程,你随时往里送消息。能带图片、能排队、能打断,权限请求和 hook 也都在这个会话里处理。官方推荐用这种。
Python 里用 ClaudeSDKClient:
import asyncio
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, ClaudeSDKClient, ResultMessage, TextBlock
async def read_one(client):
"""读到这一条的结果消息为止。"""
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print("回答:", block.text)
elif isinstance(message, ResultMessage):
print("结果:", message.subtype)
async def main():
options = ClaudeAgentOptions(model="claude-sonnet-5-5", max_turns=3, allowed_tools=[], setting_sources=["user"])
async with ClaudeSDKClient(options) as client:
await client.query("记住:暗号是 7391。只回答 OK。")
await read_one(client)
await client.query("暗号是多少?只回答数字。")
await read_one(client)
asyncio.run(main())
实测
每行开头是从启动算起的秒数:
--- 同一个会话里发两条,各读各的回答
11.1s 第一条 回答:OK
11.1s 第一条 结果:subtype=success turns=1 session=c27fd4e0
14.5s 第二条 回答:7391
14.6s 第二条 结果:subtype=success turns=1 session=c27fd4e0
两条在同一个会话里,第二条记得第一条说的暗号,不用 resume。第一条从启动算起用了 11 秒,包含起子进程的时间;第二条只用了 3 秒多。要连着问好几句的场合,用流式输入就不必每句都重新起一个子进程。
client.query() 也可以收一个异步生成器,一次交好几条:
async def two():
yield {"type": "user", "message": {"role": "user", "content": "只回答一个字:甲"}}
await asyncio.sleep(1)
yield {"type": "user", "message": {"role": "user", "content": "只回答一个字:乙"}}
await client.query(two())
--- 用生成器一次交两条消息
17.5s 生成器,第一次读 回答:甲
17.5s 生成器,第一次读 结果:subtype=success turns=1 session=c27fd4e0
21.2s 生成器,第二次读 回答:乙
21.4s 生成器,第二次读 结果:subtype=success turns=1 session=c27fd4e0
这里有 Python 版的一个特点:receive_response() 读到第一条结果消息就结束。 生成器交了两条,就要读两次。只读一次的话,第二条的回答留在那里没人收。
带图片的消息,content 写成一个列表,里面放文字块和图片块,图片用 base64。
打断
await client.query("从 1 数到 400,每个数字单独一行,不要省略。")
reader = asyncio.create_task(read_one(client))
await asyncio.sleep(4)
await client.interrupt()
await reader
--- 发一个长任务,4 秒后打断,再接着问
25.4s 调用 interrupt()
25.4s 长任务 结果:subtype=error_during_execution turns=2 session=c27fd4e0
33.4s 打断之后 回答:我还没开始数就被打断了,一个数字都没输出。
34.7s 打断之后 结果:subtype=success turns=1 session=c27fd4e0
被打断的那一轮,结果消息的 subtype 是 error_during_execution,不是抛异常。会话没有坏,接着问还能答。这一次打断得太早,它一个数都还没输出。
文档提醒的两处
- 图片块的
source缺了或者格式不对,SDK 不报错,模型收到的是一句“图片没能处理”的文字,会话继续。 - Python 里生成器抛了异常,只在调试级别记一行日志,会话就卡在那里,不会抛出来。 流式的会话没有输出也不结束时,先查生成器里的代码。
三、把会话存到自己的存储
什么时候需要
默认情况下,会话记录写在本机的 ~/.claude/projects/ 下面。只有一台机器时这就够了。下面几种情况不够:
- 服务跑在好几台机器或者无服务器的函数上,它们不共用磁盘,这台建的会话那台接不上。
- 容器随时重启,本地的文件留不住。
- 记录要放在自己管的存储里,按自己的规矩保留、加密、审计。
做法是给 SDK 一个“存储适配器”。SDK 仍然先写本地,再把同一批内容交给你的适配器,所以它是一份镜像。
接口:两个必需的方法
| 方法 | 必需 | 什么时候被调用 |
|---|---|---|
append(key, entries) |
是 | 每一批记录写到本地之后 |
load(key) |
是 | 带着 resume 启动之前。没有这个会话就返回 None |
list_sessions |
否 | 列出会话时,以及用 continue_conversation 时。没有它,“接着上一次”会报错 |
list_session_summaries |
否 | 一次读出所有会话的概要 |
delete |
否 | 删除会话时 |
list_subkeys |
否 | 恢复时找子代理的记录。没有它,只恢复主对话 |
key 里有三样:project_key 是工作目录的一种编码,session_id 是会话编号,subpath 只有子代理的记录才有。entries 是一批可以直接转成 JSON 的对象,当成不透明的东西原样存、按原来的顺序还回去,不要去解读或改动。
最快的试法:自带的内存存储
from claude_agent_sdk import ClaudeAgentOptions, InMemorySessionStore, ResultMessage, query
store = InMemorySessionStore()
# 第一次:带上 store
async for message in query(prompt="记住:暗号是 7391。只回答 OK。",
options=ClaudeAgentOptions(session_store=store, setting_sources=["user"])):
if isinstance(message, ResultMessage):
session_id = message.session_id
# 第二次:同一个 store,加上 resume
async for message in query(prompt="暗号是多少?只回答数字。",
options=ClaudeAgentOptions(session_store=store, resume=session_id, setting_sources=["user"])):
...
实测第二次回答 7391。内存存储只适合开发和测试,进程一退就没了。
自己写一个:每个会话一个文件
真正要用的是接到自己后端的适配器。先用文件练手,看清楚它到底要做什么:
import json
from pathlib import Path
class FileStore:
"""最小的适配器:每个会话一个 JSONL 文件。只实现必需的 append 和 load。"""
def __init__(self, root):
self.root = Path(root)
def _path(self, key):
name = key["session_id"] + ("__" + key["subpath"].replace("/", "__") if key.get("subpath") else "")
return self.root / key["project_key"] / (name + ".jsonl")
async def append(self, key, entries):
path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
for entry in entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
async def load(self, key):
path = self._path(key)
if not path.exists():
return None
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
二十来行。FileStore("store") 传给 session_store,用法和上面完全一样。
实测跑一次问答之后,目录里多了一个文件,再从它恢复、问第二句之后又长了几行:
第一次:OK | session=b90d972b
-home-ccdemo-demo-sdk9-more/b90d972b-….jsonl:20 行,类型 {'queue-operation': 2, 'user': 1, 'attachment': 10, 'atis-latch': 2, 'ai-title': 2, 'assistant': 1, 'last-prompt': 1, 'cost-state': 1}
从存储恢复后再问:7391
-home-ccdemo-demo-sdk9-more/b90d972b-….jsonl:28 行,类型 {'queue-operation': 4, 'user': 2, 'attachment': 11, …, 'assistant': 2, …, 'mode': 1}
能看出两件事:
- 一问一答不是两行,是二十行。 除了用户和助手的消息,还有附件、标题、花费状态等好几种内部记录。这就是为什么要“当成不透明的东西原样存”。
project_key是工作目录变出来的(/home/ccdemo/demo/sdk9-more变成了-home-ccdemo-demo-sdk9-more)。所以另一台机器要恢复,工作目录得和原来的一致。
检查适配器写得对不对
SDK 带了一套一致性检查。文档的用法是配合 pytest,本站直接调的那个函数:
import asyncio
import tempfile
from claude_agent_sdk.testing import run_session_store_conformance
# 传一个不带参数的工厂,检查每一项时都会调它新建一个空的存储
asyncio.run(run_session_store_conformance(lambda: FileStore(tempfile.mkdtemp())))
上面的 FileStore 通过了。没实现的可选方法,对应的检查会自动跳过。
官方的两个 SDK 仓库里有三个可以照抄的适配器:对象存储(S3)、键值存储(Redis)、关系数据库(Postgres)。
几条要知道的行为
- 新会话:本地那份留着,存储里是副本。不想在本地留,把
CLAUDE_CONFIG_DIR指到一个临时目录。 - 从存储恢复的那一次:SDK 把记录写进一个临时的配置目录,让子进程用它,跑完就删。所以这种情况下,存储里的是唯一一份。
- 和文件检查点不能一起用:同时开了
enable_file_checkpointing和存储,启动时就报错。 - 往存储里写是尽力而为的,这是文档那一节的标题,细节请看原文。
- 凭据:文档提醒,Python 版恢复时往临时目录里只带凭据文件,不带用户的
settings.json,靠那里面的apiKeyHelper登录的会报未登录。本站的凭据写在用户设置的env里,实测从存储恢复是成功的,和文档说的情形不完全一样,原因没有查清。上线之前请在自己的环境里试一遍。
四、报错了,先看是哪一类
Python 的 SDK 是把 Claude Code 当子进程启动的,所以报错分三层:找不到或起不来这个程序、程序中途退出、程序自己报告了一个错误。
| 异常 | 消息 | 原因 | 怎么办 |
|---|---|---|---|
CLINotFoundError |
Claude Code not found at: … |
找不到 claude 这个程序,或者 cli_path 指错了 |
装上 Claude Code;确认应用运行的环境里 claude --version 能用。从服务管理器、IDE 启动的进程,PATH 常常和终端里不一样 |
CLIConnectionError |
Failed to start Claude Code: … |
那个路径上有文件,但运行不了 | 路径要指向可执行文件本身,要有执行权限。容器里常见的是架构不对 |
CLIConnectionError |
Not connected. Call connect() first. |
还没连接就调了 ClaudeSDKClient 的方法 |
用 async with ClaudeSDKClient() as client: |
ProcessError |
Command failed with exit code … |
子进程以非零退出,而且没报告错误结果 | 给选项里传一个接收 stderr 的回调,看它实际输出了什么 |
ResultError |
Claude Code returned an error result: … |
Claude Code 自己报告了错误 | 冒号后面就是它的原话,照那句话去查 |
ResultError 是 ProcessError 的子类,它们和 CLIConnectionError 都继承自 ClaudeSDKError;CLINotFoundError 是 CLIConnectionError 的子类。想一网打尽就捕获 ClaudeSDKError。
实测到的原文
1. cli_path 指向不存在的文件
CLINotFoundError <- CLIConnectionError <- ClaudeSDKError
Claude Code not found at: /nonexistent/claude
2. cli_path 指向一个普通文本文件
CLIConnectionError <- ClaudeSDKError
Failed to start Claude Code: [Errno 13] Permission denied: '/tmp/tmpm2rmyuzn.txt'
3. 没连接就调用 client.query()
CLIConnectionError <- ClaudeSDKError
Not connected. Call connect() first.
4. 模型名写错
ResultError <- ProcessError <- ClaudeSDKError {'exit_code': 1}
Claude Code returned an error result: There's an issue with the selected model (claude-no-such-model). It may not exist or you may not have access to it. (exit code: 1)
走中转站时多出来的一个:未登录
5. setting_sources=[],而凭据写在用户设置的 env 里
ResultError <- ProcessError <- ClaudeSDKError {'exit_code': 1}
Claude Code returned an error result: Not logged in · Please run /login (exit code: 1)
本站一开始为了让实验干净,给每次运行都传了 setting_sources=[],结果八次全部是这个错。
原因是凭据的位置。按《Claude Code 接入中转站》的做法,接口地址和 key 写在 ~/.claude/settings.json 的 env 里。setting_sources=[] 的意思是“什么设置文件都不读”,用户设置也在其中,于是地址和 key 一起没了。
两种改法:
setting_sources=["user"]:读用户设置,不读项目的。本文后面的实验都是这样跑的。- 把地址和 key 放进进程的环境变量(
ANTHROPIC_BASE_URL和ANTHROPIC_AUTH_TOKEN),就和设置文件无关了。部署到服务器上时一般是这种,见《部署》。
别的症状,hook 不触发、skill 没被用、MCP 服务器连不上,各自的排查在对应功能的那一页,官方的排错页开头有一张对照表。
花费
一共二十来次模型请求,都是一两句话的问答。按 SDK 返回的估算粗算合计约 1 美元:系统提示那一组 8 次是 0.46 美元,单次多在 0.06 美元上下,大头是每次启动要写的那两万多 token 的缓存。这是估算,不是中转站实际扣的金额。
这次实测没有覆盖到的
- 只测了 Python。 TypeScript 里系统提示可以拆成数组来分段缓存,文档说走网关时不拆,本站没有测。
exclude_dynamic_sections有没有让不同用户共用缓存没有测,只确认了加上它请求照常。- 流式输入没有测带图片的消息,也没有测生成器抛异常时的表现。
- 会话存储只测了本机的文件,没有接 S3、Redis、Postgres;没有测子代理的记录、
continue_conversation、删除会话,也没有在两台机器之间恢复。 - 报错只复现了五种。
ProcessError那一条和 Windows 上批处理脚本那一条没有复现,表里是文档的说法。 - 只在一个版本、一家中转站上测过。