Claude智能体系列第 9 / 9 篇

Claude Agent SDK 补遗:系统提示从哪起步、流式输入、把会话存到自己的存储、常见报错

Agent SDK 系列前八篇没讲透的四件事:系统提示的四种起点和 CLAUDE.md 的关系,用流式输入在一个会话里连发消息和打断,用 20 行代码把会话记录存到自己的存储,以及几种常见报错各是什么原因。全部在服务器上用 Python 实测,顺带踩到一个走中转站才有的“未登录”。

发布 · 约 14 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-13。

本页目录(6 节)
  1. 一、系统提示从哪起步
  2. 二、流式输入:一个会话里连着发
  3. 三、把会话存到自己的存储
  4. 四、报错了,先看是哪一类
  5. 花费
  6. 这次实测没有覆盖到的

这一篇补四件事,都是把智能体从“能跑”做到“能上线”时会碰到的:

  1. 系统提示该用现成的、在现成的后面加一段,还是自己写?
  2. 怎么在一个会话里连着发消息,中途还能打断?
  3. 服务有好几台机器,会话记录存在哪,另一台怎么接着聊?
  4. 报错了,是哪一层的问题?

没用过 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 上批处理脚本那一条没有复现,表里是文档的说法。
  • 只在一个版本、一家中转站上测过。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章