6.11 SDK:把 Pi 当作库使用
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:前面十章都在讲「
pi这个命令怎么跑起来」。如果我不想要那个终端界面,只想在自己的 Node 程序里调用 Pi 的 Agent 能力,该从哪个函数进去?它会替我准备好哪些东西、又允许我替换掉哪些东西?以及——我怎么在完全没有 API Key 的情况下,亲手跑通一次真实的 Pi 对话? 前置知识:6.3 pi-agent-core:Agent 与循环、6.5 Session 存储格式与会话树、6.10 交互模式与 RPC 模式(本章的对照组)。 学习目标:读完后你能 ① 说出createAgentSession()会默认造出哪四个组件,以及每个组件的替换点在第几行;② 解释SessionManager.inMemory()为什么是「零副作用嵌入」的开关,并指出那个return守卫;③ 说清 SDK(软件开发工具包,Software Development Kit)嵌入与 RPC(远程过程调用,Remote Procedure Call)子进程嵌入的取舍;④ 知道为什么AgentSession之上还需要一层AgentSessionRuntime;⑤ 在没有任何 API Key 的机器上,用 100 行不到的脚本跑通一次真实的 Pi 对话(含一次工具调用)。
建立直觉:整机、发动机与遥控器
把前面十章拼起来,Pi 是一台整机:外壳是终端界面(TUI,Terminal User Interface),发动机是 AgentSession + Agent Loop(Agent 循环),油箱是 Provider(模型服务提供方)与凭证,仪表盘是会话文件。
现在你想把这台整机塞进自己的产品里,有两条路:
- 路线一:把发动机拆出来装到你的车上。你的程序和 Pi 在同一个 Node 进程里,你直接拿到
AgentSession这个对象,调它的方法、订阅它的事件。这就是 SDK。 - 路线二:不拆,给整机装一个遥控器。Pi 作为子进程跑在旁边,你用 stdin/stdout 上的 JSON 行遥控它。这就是 6.10 讲过的 RPC 模式。
new 出来(写死),也可以「你不给我就我自己造,你给了我就用你的」。后者叫依赖注入。它的好处不是抽象本身,而是可替换性:测试时换成假的,嵌入时换成不落盘的,定制时换成你自己的实现。createAgentSession() 整个函数就是一次教科书式的依赖注入。 createAgentSession:一次组装的全过程
SDK 的入口只有一个函数。它的选项接口 CreateAgentSessionOptions 有 14 个字段(sdk.ts:38-85),返回值 CreateAgentSessionResult 有 3 个字段(sdk.ts:88-95)——都是源码事实。
createAgentSession// packages/coding-agent/src/core/sdk.ts:169-185
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader;
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));
if (!resourceLoader) {
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();
time("resourceLoader.reload");
}这 17 行里有三个值得停下来看的细节:
第一,cwd 的三级回退(第 170 行):显式 cwd → 从你注入的 sessionManager 里取 → process.cwd()。第二级存在是因为恢复一个旧会话时,工作目录应该跟着会话走,而不是跟着你当前 shell 走。
第二,?? 的短路是有副作用意义的(第 179 行)。getDefaultSessionDir(cwd, agentDir) 内部会 mkdirSync 建出会话目录(packages/coding-agent/src/core/session-manager.ts:483-489)。但 JavaScript 的 ?? 只在左侧为空时才求值右侧——所以只要你注入了 sessionManager,这个建目录的动作根本不会发生。这是后面「零副作用」实践任务能成立的前提之一(源码事实;本章实践任务的沙盒目录里确实只出现了一个 auth.json)。
第三,agentDir 是否显式传入会改变 authPath/modelsPath 的取值(第 174-175 行):不传就是 undefined,ModelRuntime.create 会回落到 ~/.pi/agent 下的默认路径。想完全不碰用户真实配置,就必须显式传 agentDir。
四个默认组件与它们的替换点
| 组件 | 默认实现 | 源码行 | 什么时候该替换 |
|---|---|---|---|
ModelRuntime | ModelRuntime.create({ authPath, modelsPath }) | sdk.ts:176 | 想注入自定义 Provider、或禁止联网刷新模型目录 |
SettingsManager | SettingsManager.create(cwd, agentDir) | sdk.ts:178 | 想用固定设置而不读用户的 settings.json |
SessionManager | SessionManager.create(cwd, getDefaultSessionDir(...)) | sdk.ts:179 | 想不落盘(inMemory)、或指定会话文件 |
ResourceLoader | new DefaultResourceLoader({...}) + reload() | sdk.ts:181-185 | 想关掉技能(Skill)/扩展(Extension)/AGENTS.md 的自动发现 |
除此之外,tools / excludeTools / noTools / customTools 控制工具集合,model / thinkingLevel / scopedModels 控制模型,sessionStartEvent 传给扩展运行时。
streamFn:SDK 在这里把「设置」缝进 Agent
createAgentSession 最长的一段是构造核心 Agent。其中最关键的是 streamFn——6.3 讲过 Agent 本身是 Provider 无关的,它只认一个「给我上下文、还我事件流」的函数。SDK 在这里把 ModelRuntime 和 SettingsManager 缝了进去。
streamFn// packages/coding-agent/src/core/sdk.ts:302-330(节选)
streamFn: async (model, context, options) => {
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
// …(省略:websocketConnectTimeoutMs 的同款回退)
return modelRuntime.streamSimple(model, context, {
...options,
timeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
// …(省略:maxRetryDelayMs 与 transformHeaders 钩子)
});
},从源码结构看,这解释了一件对嵌入者很重要的事:你注入的 ModelRuntime 是唯一的出口。Agent 想说话,只能经过 modelRuntime.streamSimple。所以只要你的 ModelRuntime 里注册的是一个假 Provider,整条链路就一行网络请求都不会发——这正是本章实践任务的原理。
组装的最后一步是把所有东西交给 AgentSession:
new AgentSession图 6.11-1 createAgentSession 的组装流程
阅读顺序:从上到下。四个并列的菱形是四个可注入点,形状完全一样——这就是「依赖注入」在源码里的样子。请特别关注 sessionManager 那一路:走「否」分支(默认)会在磁盘上建目录并写文件,走「是」分支(注入 inMemory)则一个字节都不写。图中每个方框的行号都可以在 packages/coding-agent/src/core/sdk.ts 里逐行核对:ModelRuntime 在 176 行决定,SettingsManager 178 行,SessionManager 179 行,ResourceLoader 181-185 行,汇合点是 294 行的 new Agent 与 376 行的 new AgentSession。
SessionManager.inMemory:零副作用嵌入的开关
嵌入场景里最容易踩的坑是:你只想让 Pi 帮你算一次东西,结果它在用户的 ~/.pi/agent/sessions/ 下留了一堆会话文件。开关只有一行:
static inMemory// packages/coding-agent/src/core/session-manager.ts:1567-1570
/** Create an in-memory session (no file persistence) */
static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions): SessionManager {
return new SessionManager(cwd, "", undefined, false, options);
}那个 false 是构造函数的 persist 参数。它最终只在一个地方被检查:
// packages/coding-agent/src/core/session-manager.ts:1015-1016
_persist(entry: SessionEntry): void {
if (!this.persist || !this.sessionFile) return;
// …(省略:真正的写文件逻辑)这一行的意义是:内存会话不是「另一套简化实现」,而是同一套实现关掉了最后一步写文件。会话树、分支(branch)、上下文构造、压缩(Context Compaction)判定全都照常工作,你依然可以 getBranch()、buildSessionContext(),只是没有文件。
SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)) 里的 getDefaultSessionDir 会 mkdirSync 建目录(session-manager.ts:483-489),而 _persist 在出现第一条 assistant 消息后就会真的写文件。要「不留痕」,必须显式注入 SessionManager.inMemory();只把 agentDir 指到临时目录只能限制痕迹的位置,不能消除痕迹。 相关测试:packages/coding-agent/test/sdk-session-manager.test.ts 用两个用例把这对行为钉住——第 28 行 "uses agentDir for the default persisted session path" 验证默认落盘路径,第 49 行 "keeps an explicit sessionManager override" 验证注入的 SessionManager.inMemory(cwd) 会被原样保留。
会话替换:为什么还需要 AgentSessionRuntime
AgentSession 是「一次会话」的对象。但真实产品里用户会点「新建会话」「切换会话」「从某条消息分叉」。这些操作在 Pi 里不是 AgentSession 的方法,而是上面一层的 AgentSessionRuntime 的方法。
官方文档说明(来源:packages/coding-agent/docs/sdk.md:114):「Session replacement APIs such as new-session, resume, fork, and import live on AgentSessionRuntime, not on AgentSession.」同文件第 119 行进一步说明,这一层正是内建的 interactive / print / RPC 三种模式共用的那一层——6.10 已经在 main.ts:793-797 见过它。
createAgentSessionRuntime它带来一个必须记住的使用约定:runtime.session 会在替换后变成另一个对象。官方文档(sdk.md:161-167 的 “Important behavior” 列表)明确列出的后果是——事件订阅绑在具体的 AgentSession 上,替换后要重新 subscribe;用了扩展的话要重新 bindExtensions。这也是 6.10 里三种模式各自实现一个 rebindSession 回调的原因。
如果你的嵌入只需要「一问一答」,用 createAgentSession 就够了;只有当你要做一个带会话列表的界面时,才需要爬到 createAgentSessionRuntime 这一层。
进程内 SDK vs 子进程 RPC
两条嵌入路线的入口都在同一个包的同一份导出清单里(源码事实):进程内路线的 createAgentSession、createAgentSessionRuntime,以及更细粒度的 createAgentSessionServices / createAgentSessionFromServices,都在 packages/coding-agent/src/index.ts:195-221 这一个 export { ... } from "./core/sdk.ts" 块里;再往下 100 多行就是子进程路线:
RpcClient 做的事非常朴素——它就是 spawn 一个 pi 子进程:
async start图 6.11-2 两条嵌入路线
阅读顺序:左右两块独立看。请关注两块的下半部分是一样的——不管走哪条路,最终跑的都是同一个 AgentSession 和同一个 Agent Loop。差别只在上半部分:路线一是函数调用与对象引用,路线二多了一层进程边界和 JSONL(每行一个 JSON)序列化。createAgentSession 对应 sdk.ts:169,RpcClient 对应 rpc-client.ts:73,子进程里的 runRpcMode 对应 rpc-mode.ts:53(6.10 已详述)。
取舍对比(带 path:line 的格子是源码事实,其余是从源码结构与官方说明得出的分析):
| 维度 | 进程内 SDK | 子进程 RPC |
|---|---|---|
| 调用方语言 | 必须是 Node/TypeScript | 任意语言,只要能读写 JSON 行 |
| 类型安全 | 完整的 TS 类型 | 靠协议文档,运行时才发现类型错误 |
| 访问粒度 | 直接拿到 AgentSession 对象与全部 getter | 只能用 30 种 RpcCommand(rpc-types.ts:20-73) |
| 自定义工具/扩展 | customTools 选项直接传函数 | 只能靠子进程自己加载的扩展 |
| 故障隔离 | Agent 崩了你的进程也崩 | 子进程崩溃可重启,主进程不受影响 |
| 资源开销 | 无额外进程 | 每个会话一个 Node 进程 |
| 事件传递 | session.subscribe 直接拿到对象 | 事件被序列化成 JSON 行,函数/类实例会丢失 |
官方文档说明(packages/coding-agent/docs/sdk.md:1123-1133)给出的选型建议与上表一致:需要类型安全、同进程、直接访问 Agent 状态、想用代码定制工具与扩展时选 SDK;从其他语言集成、需要进程隔离、要做语言无关客户端时选 RPC。docs/rpc.md:5 也从 RPC 那一侧提醒 Node/TypeScript 用户「考虑直接用 AgentSession 而不是 spawn 子进程」。
实践任务:不用任何 Key,跑通一次真实的 Pi 对话
下面这个任务是本书的一个小高光时刻:不需要任何 API Key、不发一个网络包,但跑的是真的 Pi 核心——真的 createAgentSession、真的 AgentSession、真的 Agent Loop、真的工具执行。做法是往你注入的 ModelRuntime 里注册一个 pi-ai 自带的 faux(假)Provider——它把「模型回复」变成一段你自己写好的脚本。
fauxProviderregisterNativeProvider目标:在 Pi 仓库之外写一个只读脚本,用 createAgentSession + faux Provider + SessionManager.inMemory() 完成一次真实对话,并确认整个过程既没有联网、也没有在磁盘上留下会话文件。
前提:本地有 Pi 源码仓库(下面记作 $PI,需要是绝对路径),并且已经在仓库根跑过 npm install(本任务只用到仓库自带的 node_modules/.bin/tsx)。不需要任何 API Key。
步骤 1:新建目录与脚本(脚本放在 Pi 仓库之外,全程不修改 Pi 仓库)。
mkdir -p /tmp/pi-sdk-demo
# 用编辑器把下面的代码存成 /tmp/pi-sdk-demo/demo.mts
扩展名必须是 .mts:目录里没有 package.json,tsx 会按 CommonJS 处理 .ts 文件,而本脚本用了顶层 await。
// /tmp/pi-sdk-demo/demo.mts
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// 脚本在仓库外,所以直接按绝对路径 import 源码文件
const PI = process.env.PI_SRC ?? "/path/to/pi";
const { fauxProvider, fauxAssistantMessage } = await import(`${PI}/packages/ai/src/providers/faux.ts`);
const { ModelRuntime } = await import(`${PI}/packages/coding-agent/src/core/model-runtime.ts`);
const { SessionManager } = await import(`${PI}/packages/coding-agent/src/core/session-manager.ts`);
const { createAgentSession } = await import(`${PI}/packages/coding-agent/src/core/sdk.ts`);
const sandbox = mkdtempSync(join(tmpdir(), "pi-sdk-demo-"));
// 1. 假 Provider:脚本化一条回复,永远不会发出真实 HTTP 请求
const faux = fauxProvider();
faux.setResponses([fauxAssistantMessage("Hello from the faux provider. No API key was used.")]);
// 2. ModelRuntime:不读默认 ~/.pi,不联网
const modelRuntime = await ModelRuntime.create({
authPath: join(sandbox, "auth.json"),
modelsPath: null,
allowModelNetwork: false,
});
modelRuntime.registerNativeProvider(faux.provider);
// 3. 组装 AgentSession:内存 SessionManager + 关掉全部工具
const { session } = await createAgentSession({
cwd: sandbox,
agentDir: sandbox,
modelRuntime,
model: faux.getModel(),
sessionManager: SessionManager.inMemory(sandbox),
noTools: "all",
});
console.log("model :", session.model?.provider + "/" + session.model?.id);
console.log("sessionFile:", session.sessionFile ?? "(in-memory, no file)");
// 4. 订阅事件并发一次 prompt
let deltas = 0;
let finalText = "";
session.subscribe((event: any) => {
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") deltas++;
if (event.type === "message_end" && event.message?.role === "assistant") {
for (const block of event.message.content ?? []) if (block.type === "text") finalText += block.text;
}
});
await session.prompt("Say hello");
console.log("text_delta :", deltas, "个");
console.log("assistant :", finalText);
console.log("fauxCalls :", faux.state.callCount);
session.dispose();步骤 2:运行(把三处 /abs/path/to/pi 都换成你的绝对路径;--tsconfig 不能省,仓库内部的 @earendil-works/pi-ai 等 import 靠它的 paths 映射到 src)。
env PI_SRC=/abs/path/to/pi \
/abs/path/to/pi/node_modules/.bin/tsx \
--tsconfig /abs/path/to/pi/tsconfig.json \
/tmp/pi-sdk-demo/demo.mts
预期现象(本书在锁定版本上实测,真实输出):
model : faux/faux-1
sessionFile: (in-memory, no file)
text_delta : 3 个
assistant : Hello from the faux provider. No API key was used.
fauxCalls : 1
text_delta 的个数每次运行会变(实测在 3~4 之间):faux 把文本按「3~5 个 token」的随机块切开,而它按 4 字符≈1 token 估算,所以 49 个字符会被切成 3~4 块(packages/ai/src/providers/faux.ts:253-263)。其余四行每次都完全一样。
如何判断成功:① assistant 那行是你在 setResponses 里写的原文——说明事件确实穿过了完整的 Agent Loop 而不是被你自己打印出来的;② sessionFile 是 (in-memory, no file);③ 去沙盒目录看一眼 ls -a $(ls -d ${TMPDIR:-/tmp}/pi-sdk-demo-* | tail -1),里面只有一个 auth.json,没有 sessions/ 目录(对应正文说的 ?? 短路);④ 在 Pi 仓库里跑 git status --porcelain,输出为空。
加分项(本书同样实测跑通):让假模型真的调用一次工具。改四处——① 顶部补上 import { mkdtempSync, writeFileSync } from "node:fs";;② 从 faux.ts 的那行 await import 里多解构一个 fauxToolCall;③ 把 noTools: "all" 换成 tools: ["read"];④ 把写文件与响应队列换成下面这样:
writeFileSync(join(sandbox, "hello.txt"), "pi reads real files\n");
faux.setResponses([
fauxAssistantMessage([fauxToolCall("read", { path: "hello.txt" })], { stopReason: "toolUse" }),
fauxAssistantMessage("The file says: pi reads real files"),
]);
再把订阅回调改成打印工具调用与工具结果(在 message_end 里分别按 role === "assistant" 的 toolCall 块和 role === "toolResult" 的消息打印)。本书实测的关键输出:
[toolCall] read {"path":"hello.txt"}
[toolResult] read -> "pi reads real files\n"
[text] The file says: pi reads real files
fauxCalls: 2
注意 fauxCalls: 2:一次 prompt 触发了两轮模型调用,中间那次真的读了磁盘上的文件——这是真实的 Agent Loop 回环,只是模型是假的。
常见错误:① 忘了设 PI_SRC,会报找不到 /path/to/pi/...;② 存成 .ts 而不是 .mts,esbuild 会报 Top-level await is currently not supported with the "cjs" output format;③ 漏了 --tsconfig,仓库内部的 @earendil-works/pi-ai 解析不到(该包的 exports 指向尚未构建的 dist/);④ 队列里的响应用完后再 prompt,会收到 errorMessage: "No more faux responses queued"(本书实测确认,源码见 faux.ts:453-464);⑤ 不传 agentDir,ModelRuntime 与 SettingsManager 会去读你真实的 ~/.pi/agent。
对应源码位置:packages/coding-agent/src/core/sdk.ts:169-185(组装)、:302-330(streamFn 出口)、packages/coding-agent/src/core/model-runtime.ts:541-548(registerNativeProvider)、packages/ai/src/providers/faux.ts:523-541(fauxProvider)、packages/coding-agent/src/core/session-manager.ts:1567-1570 与 :1015-1016(inMemory 与落盘守卫)。
官方 sdk.md 对照
packages/coding-agent/docs/sdk.md 是 Pi 自带的 SDK 文档,共 1180 余行。本章核对的结论:
- Quick Start(
sdk.md:16-34)给的三行组装——ModelRuntime.create()+createAgentSession({ sessionManager: SessionManager.inMemory(), modelRuntime })+session.subscribe里过滤text_delta——与sdk.ts:169的签名和本章实践任务的写法完全一致。 sdk.md:46-64对createAgentSession()的描述(默认使用DefaultResourceLoader做标准发现)与sdk.ts:181-185一致。sdk.md:114、:119、:1123-1133三处结论(会话替换在 runtime 层、runtime 层是三种内建模式共用的层、SDK 与 RPC 的选型)都能在源码里找到对应,前两条已在本章给出行号。- 一处文档漂移:
packages/coding-agent/examples/sdk/README.md:18把第 8 个示例列为08-slash-commands.ts,而目录里实际的文件名是08-prompt-templates.ts。这类小漂移在 6.10 讲docs/json.md时也出现过——以源码为准是读 Pi 文档的默认姿势。
顺便一提,packages/coding-agent/examples/sdk/ 下有 13 个从最小到全控的可运行示例(01-minimal.ts 到 13-session-runtime.ts),官方 README 给的跑法是在 packages/coding-agent 目录下 npx tsx examples/sdk/01-minimal.ts。注意它们需要真实模型才能跑出内容——本章的实践任务是这些示例的「零 Key 版」。
本章小结
- SDK 的入口是
createAgentSession()(sdk.ts:169)。它的本质是一次依赖注入式的组装:ModelRuntime、SettingsManager、SessionManager、ResourceLoader四个组件都遵循「你不给我就我自己造」。 streamFn(sdk.ts:302-330)是 Agent 通向外界的唯一出口,它把SettingsManager的超时/重试设置与扩展的 header 钩子缝进了每一次请求。换掉ModelRuntime就能换掉整条模型链路。SessionManager.inMemory()(session-manager.ts:1567-1570)关掉的只是_persist里的最后一步写盘(:1015-1016);同时,注入sessionManager会让??短路,连会话目录都不会被创建。- 会话替换(新建/切换/分叉/导入)在
AgentSessionRuntime而不是AgentSession上;替换后runtime.session会换对象,事件订阅与扩展绑定都要重做。 - 两条嵌入路线共享同一套核心:进程内用
createAgentSession,跨语言/需隔离用RpcClientspawn 子进程(rpc-client.ts:73-97),两者的导出都在packages/coding-agent/src/index.ts。 - 关键术语:SDK(软件开发工具包)、依赖注入(Dependency Injection)、内存会话(in-memory session)、faux Provider(假 Provider)、会话替换(session replacement)。
- 关键源码索引:
packages/coding-agent/src/core/sdk.ts:169(组装)/:302-330(streamFn)/:376-398(收尾);packages/coding-agent/src/core/session-manager.ts:1567(inMemory)/:1015(落盘守卫);packages/coding-agent/src/core/model-runtime.ts:541(registerNativeProvider);packages/coding-agent/src/core/agent-session-runtime.ts:414(runtime 工厂);packages/coding-agent/src/modes/rpc/rpc-client.ts:73(子进程客户端);packages/ai/src/providers/faux.ts:523(faux Provider);测试packages/coding-agent/test/sdk-session-manager.test.ts:28,49与packages/coding-agent/test/sdk-stream-options.test.ts:132。 - 自测问题:① 你只传了
agentDir(临时目录)而没传sessionManager,磁盘上会发生什么?为什么?② 想让嵌入的 Pi 只能读文件、绝不能写文件和执行命令,CreateAgentSessionOptions里有哪两个字段可以做到,各自的语义差别是什么?③ 你的产品要做一个「会话列表 + 点开继续聊」的界面,应该用createAgentSession还是createAgentSessionRuntime?替换会话后有哪两件事必须重做? - 下一章:第六部分到此结束,接下来进入 7.1 Extension 系统——本章反复出现的
extensionRunnerRef、before_provider_headers、extensionsResult会在那里展开。 - 尚未展开的内容:
createAgentSessionServices/createAgentSessionFromServices这对更细粒度的组装函数(sdk.ts经agent-session-runtime.ts再导出)、scopedModels的模型轮换语义、sessionStartEvent的扩展启动协议,以及packages/server这个 experimental(实验性)包如何用 Unix domain socket 把 RPC 协议再包一层(6.10 已提及其定位)。