Pi SDK 上手:三层架构、Agent Loop 对照(dg P01/P02/M02/M03)
把 D2 手写的 Agent 用 Pi SDK 重写一遍,对照三层架构和源码里的 Agent Loop,体会框架帮你做了什么。
今日目标
- 能按照 Pi SDK 的三层架构说出每一层负责什么
- 能用 Pi SDK 重写 D2 的工具调用 Agent,代码量明显变少
- 能对比手写 Agent Loop 和 Pi SDK 内置 Agent Loop 的异同
昨天你亲手写完了那段 Agent 循环,大概率会有一个感觉:这段代码好像跟具体业务没什么关系,换个工具、换个任务,它还是长这样。今天我们就把这段"每个 Agent 里都一模一样"的代码交给框架,然后逐行核对一遍:你写的哪几行去了哪里,谁替你做了什么,以及你为此放弃了什么。读完回到页面顶部把三条目标勾掉。
小白版讲解
毛坯房还是精装房:框架到底在卖什么
买房交房有两种选择。毛坯:水电点位、墙面、地砖全归你,工期三个月,中间要跟七八个师傅打交道;好处是每一根管线从哪儿走你都亲眼看过,将来墙上要打个孔,你心里有数。精装交付:签完字拎包入住,一周就能睡进去;代价是墙里的走线你没见过,想改一处插座得先搞清楚开发商当初是怎么排的,甚至可能改不动。
框架和手写的关系就是这两种交付。昨天你砌的是毛坯:模型调用、循环、工具分派、结果回填、轮数保护,每一块砖都是你自己码上去的。今天要住进精装房——但你已经砌过一遍,所以你会是那种"看得懂墙里走线"的业主。
那这个"精装包"里装了什么?把昨天那段代码翻出来看,它可以清清楚楚地分成两堆:
- 跟你的业务有关的:两个工具的说明怎么写、
execute里查什么算什么、给用户看的那句话怎么组织。换个项目,这堆全要重写。 - 跟你的业务完全无关的:发 HTTP 请求、把回复追加进历史、判断停止原因决定循环继不继续、把工具名映射到函数、把执行结果包成一条消息塞回去、数轮数防死循环。换个项目,这堆一个字都不用改。
第二堆就是框架卖的东西。它把这堆通用逻辑收进一个内核,对外只留几个函数:注册工具、开一个会话、发一句话、订阅事件。你的代码从"驱动一台机器"变成"给一台机器装零件"。
工程代价要现在就说清楚:你没写的那部分不是消失了,而是变成了别人替你选的默认值。 用什么模型、系统提示词是什么、循环最多跑几轮、工具报错怎么处理——这些昨天都是你代码里明晃晃的一行,今天全藏进了内核。它们不会因为你没看见就不存在,只会在某次框架升级之后悄悄换一个值,然后线上行为跟着变,而你的代码一行没动。这就是"精装房墙里的走线",也是本章坚持要做逐行对照、而不是照着文档抄一遍 API 的原因。
Pi SDK 的三层:模型层、内核层、应用层
Pi 是一个用 TypeScript 写的 Agent SDK,它把上面那个"内核"切成了三层,每层是一个独立的 npm 包,依赖方向严格单向向下:
@earendil-works/pi-coding-agent 应用层:会话、资源装载、内置工具、四种运行模式
↓ 依赖
@earendil-works/pi-agent-core 内核层:Agent 循环、工具执行、状态管理、事件流
↓ 依赖
@earendil-works/pi-ai 模型层:统一的模型调用、鉴权、模型发现、成本统计最底层是模型调用层。 它干的事跟 D1 你手写的那个 fetch 一模一样,只是把各家厂商的差异全吃掉了:请求体格式、鉴权方式、流式分包、工具调用的字段名,对上都收敛成同一套接口,还顺手统计了 token 和花费。这一层还有一条硬约束值得记住——它只收录支持工具调用的模型,因为不能调工具的模型在 Agent 场景里根本用不了。
中间是 Agent 内核层。 它构建在模型层之上,负责的正是昨天你手写的那个循环:拿到模型回复、判断停止原因、执行工具、把结果回填、决定要不要再来一轮,以及把这整个过程用事件的形式实时播报出去。这一层是本课真正要读的那一层,因为它就是你昨天写的代码被抽象之后的样子。
最上面是应用层。 它面向"终端里的编码 Agent"这个具体场景:会话怎么存怎么恢复、扩展和技能从哪些目录加载、内置哪些工具,以及交互式、打印、进程间调用、嵌入式 SDK 这四种运行模式。今天只用最后一种——把它当一个库嵌进自己的程序。
为什么要这么切?因为每一层都能被单独换掉,也能被单独测试。只想要一个统一的模型调用层、循环自己写,就只装最底层;想要完整的 Agent 循环但不要它那套终端交互,就停在中间层。这也是判断一个框架好不好的通用方法:看它的分层能不能让你"只要一半"。反过来,如果一个框架必须整包吞下才能用,那你迟早会为用不上的那一半付出代价。
分层还有一个很实际的好处:排障时你先要判断问题出在哪一层。报错栈里出现模型层的包名,多半是鉴权、模型 id 或者请求格式的问题;出现内核层的包名,那是循环或工具执行出了问题;两者的排查方向完全不同。这个判断能力,就来自你现在花五分钟记住的这张三层图。
逐行对照:你昨天写的那些行,都去哪了
先把昨天的骨架收敛成一段最小代码,它是对照表的左半边:
// D2 的手写版:循环、分派、回填,每一行都在你自己的文件里
const MAX_STEPS = 6 // 与 D2 正文里那个上限保持一致
async function runAgent(messages) {
for (let step = 0; step < MAX_STEPS; step++) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` },
body: JSON.stringify({ model: 'openai/gpt-4o-mini', messages, tools: TOOL_SCHEMAS }),
})
const choice = (await res.json()).choices[0]
messages.push(choice.message) // 历史要自己维护
// D2 留下的锚点:停止原因决定循环继不继续
const stopReason = choice.finish_reason
if (stopReason !== 'tool_calls') return choice.message.content
for (const call of choice.message.tool_calls) {
const args = JSON.parse(call.function.arguments) // 参数校验也得自己来
let result
try {
result = await TOOL_IMPLS[call.function.name](args) // 手工分派
} catch (err) {
result = `工具执行失败:${err.message}` // 错误要自己包成一条消息
}
messages.push({ role: 'tool', tool_call_id: call.id, content: String(result) })
}
}
throw new Error('超过最大步数,可能陷入死循环')
}# D2 的手写版:循环、分派、回填,每一行都在你自己的文件里
MAX_STEPS = 6 # 与 D2 正文里那个上限保持一致
def run_agent(messages: list[dict]) -> str:
for _ in range(MAX_STEPS):
res = httpx.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"},
json={"model": "openai/gpt-4o-mini", "messages": messages, "tools": TOOL_SCHEMAS},
)
choice = res.json()["choices"][0]
messages.append(choice["message"]) # 历史要自己维护
# D2 留下的锚点:停止原因决定循环继不继续
stop_reason = choice["finish_reason"]
if stop_reason != "tool_calls":
return choice["message"]["content"]
for call in choice["message"]["tool_calls"]:
args = json.loads(call["function"]["arguments"]) # 参数校验也得自己来
try:
result = TOOL_IMPLS[call["function"]["name"]](**args) # 手工分派
except Exception as err:
result = f"工具执行失败:{err}" # 错误要自己包成一条消息
messages.append(
{"role": "tool", "tool_call_id": call["id"], "content": str(result)}
)
raise RuntimeError("超过最大步数,可能陷入死循环")// 依赖:java.net.http(JDK 11+)+ Jackson。D2 的手写版,每一行都在你自己的文件里
static final int MAX_STEPS = 6; // 与 D2 正文里那个上限保持一致
static String runAgent(ArrayNode messages) throws Exception {
for (int step = 0; step < MAX_STEPS; step++) {
JsonNode choice = postChat(messages).at("/choices/0");
messages.add(choice.get("message")); // 历史要自己维护
// D2 留下的锚点:停止原因决定循环继不继续
var stopReason = choice.at("/finish_reason").asText();
if (!"tool_calls".equals(stopReason)) {
return choice.at("/message/content").asText();
}
for (JsonNode call : choice.at("/message/tool_calls")) {
var name = call.at("/function/name").asText();
var args = MAPPER.readTree(call.at("/function/arguments").asText()); // 校验自己来
String result;
try {
result = TOOL_IMPLS.get(name).apply(args); // 手工分派
} catch (Exception err) {
result = "工具执行失败:" + err.getMessage(); // 错误要自己包成一条消息
}
var toolMessage = MAPPER.createObjectNode();
toolMessage.put("role", "tool");
toolMessage.put("tool_call_id", call.at("/id").asText());
toolMessage.put("content", result);
messages.add(toolMessage);
}
}
throw new IllegalStateException("超过最大步数,可能陷入死循环");
}// D2 的手写版:循环、分派、回填,每一行都在你自己的文件里
let maxSteps = 6 // 与 D2 正文里那个上限保持一致
func runAgent(messages: inout [ChatMessage]) async throws -> String {
for _ in 0..<maxSteps {
let choice = try await postChat(messages).choices[0]
messages.append(choice.message) // 历史要自己维护
// D2 留下的锚点:停止原因决定循环继不继续
guard choice.finishReason == "tool_calls" else {
return choice.message.content ?? ""
}
for call in choice.message.toolCalls ?? [] {
var result: String
do {
// 参数解码与手工分派都要自己写
let args = Data(call.function.arguments.utf8)
result = try await toolImpls[call.function.name]?(args) ?? "未知工具"
} catch {
result = "工具执行失败:\(error)" // 错误要自己包成一条消息
}
messages.append(ChatMessage(role: "tool", toolCallId: call.id, content: result))
}
}
throw AgentError.tooManySteps
}同样一件事,交给会话式内核之后,调用侧只剩四步:注册工具、开会话、订阅事件、发一句话。
// 真实项目里从 '@earendil-works/pi-coding-agent' 导入这四个名字;
// 本课实验用一个约 400 行的 pi-lite 复刻同一组 API,好处是离线能跑、内核可读。
import { createAgentSession, ModelRuntime, SessionManager } from './pi-lite.js'
const modelRuntime = await ModelRuntime.create()
const model = (await modelRuntime.getAvailable())[0]
const { session } = await createAgentSession({
model,
modelRuntime,
customTools: [getTimeTool, calcTool], // 只交说明书和实现,不管怎么调
sessionManager: SessionManager.inMemory(), // 历史由内核维护
})
session.subscribe((event) => {
if (event.type === 'tool_execution_start') console.log(`调用工具:${event.toolName}`)
})
// 循环、分派、回填、停止判断,全都发生在下面这一行里面
await session.prompt('现在几点?再帮我算 12 加 30 再乘 2')
session.dispose()# Pi SDK 本身是 TypeScript 的。这里给出同一套会话式内核在 Python 里的惯用形状,
# 目的是让你看清「调用侧只剩四步」这个骨架,而不是四种语言各学一个 SDK。
model_runtime = await ModelRuntime.create()
model = (await model_runtime.get_available())[0]
session = await create_agent_session(
model=model,
model_runtime=model_runtime,
custom_tools=[get_time_tool, calc_tool], # 只交说明书和实现,不管怎么调
session_manager=SessionManager.in_memory(), # 历史由内核维护
)
def on_event(event: AgentEvent) -> None:
if event.type == "tool_execution_start":
print(f"调用工具:{event.tool_name}")
session.subscribe(on_event)
# 循环、分派、回填、停止判断,全都发生在下面这一行里面
await session.prompt("现在几点?再帮我算 12 加 30 再乘 2")
session.dispose()// Pi SDK 是 TypeScript 的;这里是同一套会话式内核在 Java 里的惯用形状
var modelRuntime = ModelRuntime.create();
var model = modelRuntime.getAvailable().get(0);
var config = new SessionConfig(model, modelRuntime,
List.of(GET_TIME_TOOL, CALC_TOOL), // 只交说明书和实现,不管怎么调
SessionManager.inMemory()); // 历史由内核维护
// AgentSession 实现 AutoCloseable,用 try-with-resources 代替手写 dispose()
try (var session = AgentSession.create(config)) {
session.subscribe(event -> {
if (event instanceof ToolExecutionStart start) {
System.out.println("调用工具:" + start.toolName());
}
});
// 循环、分派、回填、停止判断,全都发生在下面这一行里面
session.prompt("现在几点?再帮我算 12 加 30 再乘 2");
}// Pi SDK 是 TypeScript 的;这里是同一套会话式内核在 Swift 里的惯用形状
let modelRuntime = try await ModelRuntime.create()
guard let model = try await modelRuntime.available().first else { throw AgentError.noModel }
let session = try await AgentSession(
model: model,
modelRuntime: modelRuntime,
customTools: [getTimeTool, calcTool], // 只交说明书和实现,不管怎么调
sessionManager: .inMemory() // 历史由内核维护
)
defer { session.dispose() } // defer 保证提前抛错也会清理
let unsubscribe = session.subscribe { event in
if case .toolExecutionStart(let name) = event { print("调用工具:\(name)") }
}
defer { unsubscribe() }
// 循环、分派、回填、停止判断,全都发生在下面这一行里面
try await session.prompt("现在几点?再帮我算 12 加 30 再乘 2")现在把两边并排,这张表就是今天最值钱的东西:
| 你昨天写的这几行 | 在 Pi 里对应什么 | 谁负责 |
|---|---|---|
fetch 打模型接口、拼鉴权头 | 模型调用层的统一接口,由 ModelRuntime 挑出可用模型 | 框架 |
messages.push 手工维护历史 | 会话内部持有消息列表,SessionManager 决定存哪儿 | 框架 |
for 循环本身 | 内核的 Agent 循环,外部只看到一句 await session.prompt() | 框架 |
| 判断停止原因决定继不继续 | 同一个判断挪进了内核,字段叫 stopReason,工具分支的取值是 toolUse | 框架 |
tools 数组里那份 JSON Schema | defineTool 的 parameters | 你 |
TOOL_IMPLS[name] 手工分派 | 内核按工具名找到对应的 execute | 框架 |
JSON.parse 参数再手工校验 | 内核按 schema 校验完,把结构化参数交给你 | 框架 |
| 把结果包成一条 tool 消息塞回去 | execute 的返回值,内核负责回填 | 框架 |
try/catch 把错误字符串回传 | execute 里直接抛异常,内核转成带错误标记的工具结果回给模型 | 框架 |
MAX_STEPS 步数上限 | 内核的循环控制与停止钩子,但上限该设多少仍然由你决定 | 一起 |
| 工具里真正查什么、算什么 | execute 的函数体 | 你 |
这张表里有一条最值得盯着看:昨天那个 stop_reason 判断没有消失。 很多人以为用了框架就"没有循环了",其实循环还在,停止原因还在,只是从你的 if 里挪进了内核的 if 里。Pi 把这个字段叫 stopReason,取值包括正常结束、命中长度上限、要调工具、出错、被取消——工具分支的取值就是 toolUse,它对应的正是昨天你判断"要不要再来一轮"的那一行。你在外面感知不到它,是因为你换了一种观察方式:不再靠读返回值,而是靠订阅事件(下一节讲)。
messages 数组(每轮都要整个重发一遍)
顺带说一个数字:昨天那个 Agent 大约 120 行,其中真正属于业务的不到 30 行;换成会话式内核之后,同样的功能落到 40 行上下,业务那 30 行原封不动。省下来的 80 行,正好就是表格里"框架负责"的那些格子。
该用框架还是该手写:一个可执行的判据
省 80 行代码听起来是稳赚,但工程上从来没有稳赚的事。给你一条可执行的判据:问自己"我需不需要看见并改动这段循环里的每一步"。
需要,就手写。 三种常见情形:一是学习和调试阶段,你要的就是每一步可见,这正是昨天那节课的意义;二是行为必须逐字可控,比如合规要求每次模型调用都落审计日志、每次工具调用都过审批,框架的钩子未必开在你要的位置;三是场景本身极简——只有一两个工具、循环最多两轮,那 40 行的收益抵不过引入一整套依赖的成本(一个完整的 Agent 框架装下来往往是上百兆的依赖树,对冷启动敏感的场景要先算这笔账)。
不需要,就用框架。 判断标准是这几件事你是不是迟早都要做:工具超过五个、要把 Agent 的每一步实时推给前端、会话要能存下来重启后继续、上下文塞满了要自动压缩、要能随时换模型。这些每一件单独看都不难,凑齐了就是一个小型框架——自己写等于重新发明一个,而且是没人帮你测的那一个。
真正要提防的不是"用框架",是"用了框架但不知道它替你做了什么"。三个具体后果:
第一,排障时栈变深了。工具没被调用,可能是描述写得模型看不懂、schema 校验没过、也可能是内核的某个钩子拦下了。知道分层的人会先看事件流里工具执行的事件有没有发出来——发了就是执行的问题,没发就是模型压根没想调它。
第二,你继承了一堆没写过的默认值。模型是它替你挑的,系统提示词是它替你塞的,内置工具是它替你注册的。
第三,升级会改变你没测过的行为。默认值变了,代码一行没动,线上表现却变了——这类问题极难定位,因为你的第一反应一定是"我又没改代码"。
一句话收尾:先手写一遍再用框架,你付出的是一天,省下的是每次排障的半天。 这也是本课把 D2 排在 D3 前面的全部理由。
注册工具与订阅事件:你在框架里真正要写的两件事
用了框架之后,你的代码主要就剩两件事:把工具交进去,把事件收回来。
先说工具。 一个工具由三部分组成:一份给模型看的说明书(名字、描述、参数 schema)、一段真正干活的实现、以及一次注册。说明书里最容易被低估的是 description——它不是给同事看的注释,是模型判断"要不要调这个工具、什么时候调"的唯一依据,写得含糊,模型就会该调的时候不调。这一条 D5 会专门展开。
import { defineTool } from './pi-lite.js' // 真实项目里从 '@earendil-works/pi-coding-agent' 导入
export const calcTool = defineTool({
name: 'calc',
label: '四则运算', // 只给界面展示用,模型看不到
// description 才是写给模型看的:它靠这段话决定要不要调、什么时候调
description: '计算一个只含数字、加减乘除与括号的四则运算表达式,返回计算结果。',
// TS 的类型编译后就没了、运行时读不到,所以 schema 只能照着类型再写一遍;
// 下面三门语言运行时还留着类型信息,可以直接拿类型声明当 schema
parameters: {
type: 'object',
properties: {
expression: { type: 'string', description: '要计算的表达式,例如 12 加 30 再乘 2' },
},
required: ['expression'],
},
// params 已经按上面的 schema 校验过了,直接用;失败就抛,内核会把错误回传给模型
async execute(_toolCallId, params) {
return { content: [{ type: 'text', text: String(evaluate(params.expression)) }], details: {} }
},
})from dataclasses import dataclass
@dataclass
class CalcParams:
expression: str
async def run_calc(tool_call_id: str, params: CalcParams) -> ToolResult:
# params 已经按 schema 校验过了;失败直接 raise,内核会把错误回传给模型
return ToolResult(content=[TextPart(text=str(evaluate(params.expression)))])
calc_tool = define_tool(
name="calc",
label="四则运算", # 只给界面展示用,模型看不到
# description 才是写给模型看的:它靠这段话决定要不要调、什么时候调
description="计算一个只含数字、加减乘除与括号的四则运算表达式,返回计算结果。",
# dataclass 直接当 schema,由内核转成 JSON Schema 并负责校验
parameters=CalcParams,
field_descriptions={"expression": "要计算的表达式,例如 12 加 30 再乘 2"},
execute=run_calc,
)// record 就是参数 schema:字段名与类型由内核转成 JSON Schema 并负责校验
record CalcParams(
@JsonPropertyDescription("要计算的表达式,例如 12 加 30 再乘 2") String expression) {}
static final AgentTool<CalcParams> CALC_TOOL = AgentTool.<CalcParams>builder()
.name("calc")
.label("四则运算") // 只给界面展示用,模型看不到
// description 才是写给模型看的:它靠这段话决定要不要调、什么时候调
.description("计算一个只含数字、加减乘除与括号的四则运算表达式,返回计算结果。")
.parameters(CalcParams.class)
// params 已按 schema 校验过;失败直接抛异常,内核会把错误回传给模型
.execute((toolCallId, params) -> ToolResult.text(String.valueOf(evaluate(params.expression()))))
.build();// Codable 的 struct 就是参数 schema,由内核转成 JSON Schema 并负责校验
struct CalcParams: Codable {
let expression: String
static let fieldDescriptions = ["expression": "要计算的表达式,例如 12 加 30 再乘 2"]
}
let calcTool = defineTool(
name: "calc",
label: "四则运算", // 只给界面展示用,模型看不到
// description 才是写给模型看的:它靠这段话决定要不要调、什么时候调
description: "计算一个只含数字、加减乘除与括号的四则运算表达式,返回计算结果。",
parameters: CalcParams.self
) { _, params in
// params 已按 schema 校验过;失败直接 throw,内核会把错误回传给模型
ToolResult.text(String(evaluate(params.expression)))
}注意 execute 里那句"失败就抛"。这跟很多人的直觉相反:写业务代码时我们习惯把异常兜住、返回一个错误对象。但在 Agent 里,工具的异常应该抛给内核,由内核转成一条带错误标记的工具结果回给模型——模型看到"文件不存在"这样的报错,下一轮往往会自己改参数重试。你要是把异常吞了、返回一句"操作失败"当正常结果,模型反而以为工具跑成功了。这条规则你昨天是用 try/catch 手动实现的,今天变成了框架的约定。
再说事件。 昨天想知道 Agent 在干什么,只能在循环里插 console.log。现在循环不归你管了,观察靠订阅。一次完整的 prompt 大致会发出这些事件:
agent_start 一次 prompt 开始
├─ turn_start 一轮开始(一次模型调用 + 这一轮的工具执行)
│ ├─ message_start 一条消息开始(assistant 或 toolResult)
│ ├─ message_update 文本增量,逐 token 推送,非常频繁
│ ├─ message_end 一条消息结束
│ ├─ tool_execution_start 工具开始执行
│ ├─ tool_execution_update 工具执行中的进度片段
│ └─ tool_execution_end 工具执行结束
├─ turn_end 一轮结束,带上这一轮的工具结果
└─ agent_end 循环结束,之后不会再有事件把这张表和上一节的对照表叠起来看,会发现一个很漂亮的对应:turn_start 和 turn_end 就是你昨天那个 for 循环的一次迭代,agent_end 就是你 return 的那一刻。 D1 里你手写 SSE 解析拿到的那个逐字文本流,这里就是 message_update 携带的文本增量——同一件事,换了个观察位置。
两个坑要提前说。一是 message_update 逐 token 触发、频率极高,千万别在这个回调里做重活——写库、发请求、跑正则都会把整条流式链路拖慢,正确做法是攒一小段再批量处理。二是事件订阅是可观测性的入口:耗时统计、工具成功率、成本归集全从这里接出去,这是 D5 要展开的内容。
源码导读
动手实验
动手之前先确认一件事:实验里的内核是一个约 400 行、近四分之一是注释的 pi-lite.ts,API 形状与 Pi 的公开接口一一对应。这样安排是为了让你既能离线跑通,又能真的打开内核看一眼那段循环——精装房的墙拆开给你看一次,比看一百页文档管用。README 里给了换成真实 SDK 的那几行改动。
- 先原样跑
MOCK=1 pnpm start:模型只调到一个工具,还直说"我手上没有计算工具"——记住这个不完整的输出,它是对比的起点。 - 把工具的说明书写清楚:
description和字段描述都是写给模型看的,含糊了模型就不敢调。重跑,看假模型从"判断不出该不该用它"变成真的调了它。 - 把第二个工具注册进会话,重跑后确认两个工具都被调用到。
- 补上轮次事件的订阅,数一数这次对话跑了几轮,对照昨天那个
for循环的迭代次数。 - 去掉
execute里那段吞异常的try/catch,加--tool-error重跑看模型下一轮的反应;再打开pi-lite.ts,对着正文那张对照表逐格找到实现位置。
面试题
今天 4 道题在下方题库区,侧重 Agent 循环的设计要点与"框架还是手写"的权衡。展开后先看"分析过程"再看要点——第 2 题的追问(框架默认值带来的升级风险)是这一章最容易被追着问的地方,别跳过。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能按照 Pi SDK 的三层架构说出每一层负责什么
- 能用 Pi SDK 重写 D2 的工具调用 Agent,代码量明显变少
- 能对比手写 Agent Loop 和 Pi SDK 内置 Agent Loop 的异同
- 能指着昨天手写循环里的每一行,说出它在框架里由谁负责,尤其是判断停止原因的那一行
- 实验的 5 条验收标准全部通过
- 4 道面试题不看要点也能答出至少 3 道
明天(D4)我们要动的正是今天这两颗定时炸弹。第一颗是模型:框架替你挑了一个,我们把模型调用层拆出来自己接三家,并让它在某一家出问题时自动换下一家。第二颗是人设:那段你从没写过、却决定了 Agent 怎么说话的默认系统提示词,我们要把它找出来、用自己写的一段覆盖掉并验证生效。先框架后拆解是有意的顺序——你得先看见框架替你填了哪些空,才知道该往回夺哪几样控制权。
面试题库
Agent 框架内部的 Agent Loop 一般要解决哪些问题?What problems does an agent framework's built-in agent loop have to solve?
国内高频海外高频基础#agent-loop#framework-design分析过程 · 先想清楚再作答
- 这题看着像背清单,区分度其实在「你有没有自己写过一遍」。只答「循环调用模型直到结束」会被认为读过文档但没写过代码。
- 最稳的拆法是把手写版的代码从上往下念一遍,每一行都是内核必须解决的一件事:发模型请求、维护消息历史、判断停止原因决定继不继续、按工具名分派、按 schema 校验参数、把工具结果回填成一条消息、控制最大轮数。这条链路念完,答案自然是完整的。
- 点名停止原因这一环最能加分:循环的出口条件不是「模型说完了」,而是这一轮的停止原因是不是「要调工具」。很多人把它含糊过去,而它恰恰是整个循环的开关。
- 然后补上手写版通常没做、但框架必须做的三件:并发执行同一批工具调用、把每一步以事件形式播报出去(否则外部完全是黑箱)、以及上下文超限时的压缩与会话持久化。
- 最后落到工具报错这一条,它是最能体现工程经验的:工具异常不应该被吞掉,要转成一条带错误标记的工具结果回给模型,让模型自己改参数重试;吞掉异常返回一句「操作失败」,模型会以为工具成功了。
- 可以预期的追问:怎么防死循环?答最大轮数只是兜底,更实际的是给单次运行设 token 与耗时预算,并在工具调用前留一个可以拦截的钩子,触发条件时把拦截原因回传给模型让它改道。
How to reason about it · think before answering
- This looks like a checklist question, but the real signal is whether you have written such a loop yourself. 'Call the model repeatedly until it stops' reads as documentation-only knowledge.
- The safest structure is to walk down your own hand-written loop line by line, because every line is one problem the kernel must own: issue the model request, maintain message history, decide from the stop reason whether to continue, dispatch by tool name, validate arguments against the schema, fold the tool result back in as a message, and cap the number of turns.
- Naming the stop reason explicitly scores well: the loop exits not when 'the model finished talking' but when the turn's stop reason is not a tool-use one. Most candidates blur past this, and it is the switch that drives the whole loop.
- Then add the three things a hand-rolled version usually skips but a framework cannot: running a batch of tool calls concurrently, emitting the whole run as an event stream so callers are not staring at a black box, and compaction plus session persistence once the context outgrows the window.
- Close on tool errors, which is where production experience shows: a failing tool should raise, and the kernel should turn that into a tool result flagged as an error so the model can fix its arguments and retry. Swallowing the exception and returning 'operation failed' as a normal result makes the model believe the tool succeeded.
- Expect the follow-up: how do you stop runaway loops? A max-turn cap is only a backstop; per-run token and wall-clock budgets plus a pre-execution hook that can block a call and hand the reason back to the model are what actually work.
答题要点
- 循环骨架:调模型、维护消息历史、按停止原因判断继不继续、分派工具、校验参数、回填工具结果
- 停止原因是循环的出口条件,工具分支意味着还要再来一轮,其他取值意味着结束
- 工具执行的工程细节:同一批调用可以并发、执行前后要留钩子、异常要转成带错误标记的工具结果回给模型
- 对外要有事件流,否则调用方看不到 Agent 在做什么,也没法做可观测性
- 安全阀:最大轮数、token 与耗时预算、上下文超限时的压缩,以及会话的持久化与恢复
Key points
- The skeleton: call the model, maintain history, branch on the stop reason, dispatch tools, validate arguments, fold results back in
- The stop reason is the loop's exit condition — a tool-use reason means one more turn, anything else means done
- Tool execution details: batch calls can run concurrently, hooks belong before and after, and exceptions become error-flagged tool results the model can react to
- An event stream is mandatory, otherwise callers see a black box and observability is impossible
- Safety valves: max turns, token and latency budgets, context compaction, and session persistence for resume
选择使用 Agent 框架还是手写 Agent,你会怎么权衡?How do you decide between adopting an agent framework and hand-rolling the loop?
国内高频海外高频进阶#framework-design#engineering-tradeoffs分析过程 · 先想清楚再作答
- 题眼在「权衡」。答「框架更快」或者「手写更可控」都只说了一半,面试官想听的是你有没有一条能当场执行的判据,而不是立场。
- 给判据:问自己「我需不需要看见并改动这段循环里的每一步」。需要就手写——学习调试阶段、合规审计要求每次模型调用和工具调用都可拦截可留痕、或者场景本身只有一两个工具两三轮循环,那点代码量的收益抵不过一整棵依赖树。
- 不需要就用框架,判断标准是这几件事你是不是迟早都要做:工具数量上去、要把每一步实时推给前端、会话要能重启后继续、上下文满了要压缩、要随时换模型。这些凑齐了就是一个小型框架,自己写等于重新发明一个没人帮你测的版本。
- 然后主动说出框架的三笔代价,这是区分度所在:一是排障栈变深,工具没被调用可能是描述、schema、钩子拦截三种完全不同的原因;二是你继承了一堆没写过的默认值,模型、系统提示词、内置工具都是别人替你选的;三是升级会改变你没测过的行为,代码一行没动线上表现却变了,这类问题最难定位。
- 结论要给出可落地的折中:先手写一遍把循环吃透,再上框架;上了框架也要显式覆盖掉默认值,并把框架版本锁死。这样既拿到了开发速度,也没把行为的控制权整个交出去。
- 可以预期的追问:那你怎么评估一个框架好不好?答看它的分层能不能让你「只要一半」——只要模型调用层、循环自己写行不行;必须整包吞下的框架,迟早要为用不上的那一半付代价。
How to reason about it · think before answering
- The hinge word is 'decide'. 'Frameworks are faster' and 'hand-rolling is more controllable' are each half an answer; what earns points is a criterion you can apply on the spot rather than a preference.
- Offer the criterion: ask whether you need to see and change every step inside the loop. If yes, hand-roll — during learning and debugging, under compliance rules that require every model call and tool call to be interceptable and auditable, or when the scenario really is two tools and three turns and the saved lines do not justify a large dependency tree.
- If no, take the framework, and justify it by what you will inevitably need anyway: more tools, streaming every step to a UI, sessions that survive a restart, compaction when context fills up, swapping models on demand. Assemble all of those yourself and you have written a small framework — an untested one.
- Then volunteer the three costs, which is where the signal is: debugging spans more layers, so a tool that never runs could be a bad description, a schema rejection, or a hook that blocked it; you inherit defaults you never wrote, including the model, the system prompt, and the built-in tools; and upgrades change behavior you never tested, which is brutal to diagnose because your own code did not change.
- Land on a practical middle: hand-roll once to internalize the loop, then adopt a framework, override its defaults explicitly, and pin its version. You keep the delivery speed without handing over control of behavior.
- Expect the follow-up: how do you judge a framework? By whether its layering lets you take only half of it — model layer only, loop your own. Anything you must swallow whole will eventually bill you for the half you do not use.
答题要点
- 判据是「需不需要看见并改动循环里的每一步」,需要就手写,不需要就用框架
- 手写更合适:学习调试、合规要求每步可拦截可留痕、场景极简、对依赖体积与冷启动敏感
- 框架更合适:工具多、要事件流、要会话持久化与压缩、要多模型——这些凑齐等于自己造一个框架
- 框架的三笔代价:排障栈变深、继承一堆没写过的默认值、升级会改变没测过的行为
- 折中做法:先手写吃透循环再上框架,显式覆盖默认值并锁死版本
Key points
- The criterion is whether you need to see and modify every step of the loop
- Hand-roll for learning and debugging, for compliance that demands interceptable and auditable steps, for genuinely tiny scenarios, and where dependency size or cold start matters
- Use a framework once you need many tools, an event stream, persistent sessions, compaction, and model swapping — building all of that is writing a framework yourself
- Three costs: deeper debugging surface, inherited defaults you never wrote, and upgrades that shift untested behavior
- The middle path: hand-roll once, then adopt, override defaults explicitly, and pin the version
Pi SDK 的三层架构分别对应什么职责?这样分层解决了什么问题?What are the responsibilities of Pi SDK's three layers, and what does that layering buy you?
国内高频海外高频基础#framework-design#architecture分析过程 · 先想清楚再作答
- 前半句是记忆题,后半句才有区分度。只背出三个包名而说不出「为什么这么切」,面试官会判断你只是照着文档看了一遍。
- 先把三层说准:最底层是统一的模型调用层,负责把各家 provider 的请求格式、鉴权、流式分包收敛成一套接口,还统计 token 与成本;中间是 Agent 内核层,构建在模型层之上,负责 Agent 循环、工具执行、状态管理和事件流;最上层是应用层,负责会话存取、扩展与资源装载、内置工具,以及交互式、打印、进程间调用、嵌入式 SDK 这几种运行模式。依赖方向严格单向向下。
- 然后回答「解决了什么」:分层的价值是让你能「只要一半」——只想要统一的模型调用层就停在最底层,想要完整循环但不要终端交互就停在中间层。这条判据可以用来评估任何框架,比复述包名有用得多。
- 补一个很实际的收益:排障时先判断问题落在哪一层。报错栈里出现模型层,多半是鉴权、模型 id 或请求格式;出现内核层,那是循环或工具执行;两者的排查方向完全不同。
- 可以预期的追问:这套分层跟你手写的版本怎么对应?答手写版把三层揉在了一个文件里——fetch 那几行是模型层,while 循环和工具分派是内核层,命令行交互是应用层。能当场做这个映射,比任何背诵都有说服力。
How to reason about it · think before answering
- The first half is recall; the second half carries the signal. Reciting three package names without explaining the cut suggests you only skimmed the docs.
- State the layers precisely: the bottom is a unified model layer that normalizes each provider's request format, auth and streaming into one interface while tracking tokens and cost; the middle is the agent kernel built on top of it, owning the agent loop, tool execution, state and the event stream; the top is the application layer, owning session storage, extension and resource loading, built-in tools, and the interactive, print, RPC and embedded-SDK run modes. Dependencies point strictly downward.
- Then answer what it buys: layering lets you take only half. Want just a unified model layer and your own loop? Stop at the bottom. Want the full loop but none of the terminal UX? Stop in the middle. That test generalizes to any framework and is worth far more than the package names.
- Add the practical payoff: when something breaks, first place it in a layer. A stack trace through the model layer points at auth, a wrong model id or a malformed request; one through the kernel points at the loop or tool execution. The two investigations look nothing alike.
- Expect the follow-up: how does this map onto the loop you wrote by hand? All three layers were collapsed into one file — the fetch calls were the model layer, the while loop and tool dispatch were the kernel, and the CLI was the application layer. Making that mapping live is more convincing than any recitation.
答题要点
- 模型层:统一各家 provider 的请求格式、鉴权与流式,附带 token 与成本统计,只收录支持工具调用的模型
- 内核层:Agent 循环、工具执行与结果回填、状态管理、事件流,构建在模型层之上
- 应用层:会话存取、扩展与资源装载、内置工具,以及交互式、打印、进程间调用、嵌入式 SDK 几种运行模式
- 依赖单向向下,好处是每层可单独替换、单独测试,也能「只要一半」
- 排障时先定位问题落在哪一层,模型层和内核层的排查方向完全不同
Key points
- Model layer: normalizes provider request formats, auth and streaming, tracks tokens and cost, and only ships tool-calling models
- Kernel layer: the agent loop, tool execution and result folding, state management and the event stream, built on the model layer
- Application layer: session storage, extension and resource loading, built-in tools, and the interactive, print, RPC and embedded-SDK run modes
- Dependencies point one way, so each layer is replaceable and testable on its own and you can adopt only part of the stack
- For debugging, place the failure in a layer first — model-layer and kernel-layer investigations diverge immediately
用了 Agent 框架之后,你怎么知道它内部到底发生了什么?出问题从哪里查?Once you adopt an agent framework, how do you know what it is doing internally, and where do you start debugging?
国内高频海外高频深入#observability#framework-design#debugging分析过程 · 先想清楚再作答
- 这题是「框架 vs 手写」那道题的实操版,考的是你有没有在框架上真的排过障。答「打日志」是最弱的答案,因为循环已经不在你的代码里了,你没有地方插日志。
- 先给正确的观察位置:框架的事件流。一次执行会依次发出运行开始、每一轮的开始与结束、消息的开始与增量与结束、工具执行的开始与结束、运行结束。这些事件就是循环的每一步在外部的投影——轮次的开始与结束对应手写版 for 循环的一次迭代,运行结束对应你 return 的那一刻。
- 给一条可复用的排查链:以「工具没被调用」为例,先看事件流里有没有发出工具执行开始的事件。发出了就是执行阶段的问题(参数、实现、超时);没发出就说明模型压根没决定调它,问题在工具描述或参数 schema,跟工具实现一点关系都没有。这条二分法能省掉大量瞎试。
- 补上另外两条线索:一是分层定位,报错栈落在模型层就查鉴权、模型 id 与请求格式,落在内核层就查循环与工具执行;二是把框架版本锁死,因为默认值随版本变化,「代码一行没改但行为变了」这类问题的第一嫌疑人就是升级。
- 生产视角要主动说:事件流不只是调试用的,它是可观测性的接入点——每一步耗时、工具成功率、token 与成本归集都从这里接出去。但要提醒一句,文本增量事件是逐 token 触发的,回调里做重活会拖慢整条流式链路,正确做法是攒一批再处理。
- 可以预期的追问:如果框架没有暴露你需要的那个钩子怎么办?答先看它的分层能不能降一层用(比如绕过应用层直接用内核层),再考虑用它的扩展机制在工具调用前后插手;实在不行才是 fork,而 fork 的代价是你从此要自己跟上游合并。
How to reason about it · think before answering
- This is the hands-on version of the framework-versus-hand-rolling question, and it tests whether you have actually debugged on top of a framework. 'Add logging' is the weakest answer, because the loop is no longer in your code and there is nowhere to add it.
- Name the right observation point: the event stream. One run emits run start, each turn's start and end, message start and deltas and end, tool execution start and end, and run end. Those events are the loop's steps projected outward — turn start and end correspond to one iteration of your hand-written for loop, and run end to your return statement.
- Give a reusable triage chain, taking 'the tool never ran' as the example: check whether a tool-execution-start event was emitted. If it was, the problem lives in execution — arguments, implementation, timeout. If it was not, the model never decided to call it, so the problem is the tool description or the parameter schema and has nothing to do with the implementation. That single split removes most guesswork.
- Add two more threads: locate the failure by layer, since a model-layer stack points at auth, model id or request shape while a kernel-layer stack points at the loop or tool execution; and pin the framework version, because defaults shift between releases and 'behavior changed with no code change' almost always means an upgrade.
- Volunteer the production angle: the event stream is not just for debugging, it is the observability seam where per-step latency, tool success rate and token or cost accounting are collected. Warn that text-delta events fire per token, so heavy work in that callback stalls the stream — batch first, then process.
- Expect the follow-up: what if the framework does not expose the hook you need? Try dropping a layer first (bypass the application layer and drive the kernel directly), then its extension mechanism for intercepting around tool calls; forking is the last resort, and its real price is owning upstream merges forever.
答题要点
- 观察位置是框架的事件流,不是日志:运行开始、轮次开始与结束、消息增量、工具执行开始与结束、运行结束
- 轮次的开始与结束对应手写版循环的一次迭代,运行结束对应 return,能做这个映射就能读懂任何事件表
- 排查二分法:工具没被调用时,先看有没有发出工具执行开始的事件——发了查实现,没发查描述与 schema
- 按分层定位:模型层的栈查鉴权与模型 id,内核层的栈查循环与工具执行;同时锁死框架版本,升级是行为变化的第一嫌疑人
- 事件流也是可观测性接入点,但文本增量事件极其频繁,回调里不要做重活,攒一批再处理
Key points
- Observe through the event stream, not ad-hoc logs: run start, turn start and end, message deltas, tool execution start and end, run end
- Turn start and end map to one iteration of the hand-written loop, and run end maps to the return — that mapping makes any event table readable
- Triage split: if a tool never ran, check for a tool-execution-start event; present means debug the implementation, absent means debug the description and schema
- Locate by layer — model-layer stacks mean auth or model id, kernel-layer stacks mean the loop or tool execution — and pin the framework version, since upgrades silently move defaults
- The event stream is also the observability seam, but text deltas fire per token, so batch before doing real work in that callback
评论
登录后即可参与讨论
还没有评论,来说第一句。