INTERVIEW 30 Q&A

AI Agent 面试题:30 问 30 答

全部题目来自《PI agent 学习指南》十章,答案基于开源项目 Pi(GitHub 近 8 万 Star)的生产级源码, 不是网上抄来的八股文。每题附「展开阅读」,答不上来的地方点进去看完整拆解。

01

Pi-Agent 总览:一个框架的三重身份

读全章 →

Pi 把会话存成树结构而不是线性日志,这在工程上解决了什么实际问题?

线性日志只能追加,回退等于删除。树结构(DAG)让每条历史消息都能成为分叉起点:用 /tree 跳到任意节点开新分支,同一起点可并行尝试多种方案,所有分支共存于一个文件——调试时永远'回得去',探索成果不丢。

让你给自己的 Agent 接入国内的 DeepSeek 或智谱模型,仅靠环境变量够吗?还差哪些配置?

不够。环境变量只能给官方默认供应商配 key。接国内厂商要写 ~/.pi/agent/models.json:声明 baseUrl、api 协议(通常是 openai-completions)、apiKey,以及 models 的 id/name,可选 contextWindow/maxTokens。启动时 ModelRegistry 自动读取,/model 即可切换。

Pi 默认采用无审批的 YOLO 模式,作者用什么理由来辩护这种'不安全'的设计?

Mario 认为审批弹窗会导致'弹窗疲劳':用户最终看都不看就机械点同意,安全措施沦为'安全表演',反而制造虚假安全感。他主张以容器化作为真正的安全边界;确实需要审批时,用框架暴露的钩子写约 50 行扩展即可自行实现。

02

三层架构:Pi-Agent 项目的骨骼

读全章 →

Pi 的 coding-agent 直接依赖了最底层的 pi-ai,这算不算破坏了分层?为什么?

不算破坏。分层的真正规则不是'只能依赖相邻层',而是依赖方向单向向上、底层不知上层存在。Message/Model/Tool 是全系统的'原子类型',必须在一处定义、各层引用;只要 pi-ai 不反向引用任何上层包,跨层引用类型就是必然且健康的。

Pi 的 Tool 类型在三个包里分别是 Tool / AgentTool / ToolDefinition,这种递进扩展比直接改底层类型好在哪里?

底层类型保持最小且永不被修改:Tool 只含 LLM 需要的名字和参数 schema,AgentTool 用继承加执行能力,ToolDefinition 再加产品层的渲染与提示字段。扩展用联合类型和继承而非改原类型,底层可独立发布复用,上层需求变化不会震垮地基。

如果让你验证一个分层架构是否健康,你会用什么可操作的方法来检查?

两个可操作检查:①对每层问'去掉上层,这一层还能跑吗?'能跑则方向正确;②在 package.json 里移除上层依赖,看底层包的编译和测试是否仍通过。任何报错都说明上层概念泄漏到了下层,依赖方向出现了反向。

03

Agent Loop:让模型转动起来的引擎

读全章 →

如果模型在一次循环里连续调用工具,框架怎么知道什么时候该停?

循环只看一个信号:模型输出里还有没有 toolCall 块。有就执行工具、把结果喂回去继续转;没有(stop 或 length 且无待处理消息)就停。模型只是 token 预测器,不负责判断'任务完成'——停止规则由框架定义,这是 Agent 与 Workflow 的本质区别。

stopReason 有五种取值,但其中两种并不是模型 API 返回的——它们是谁、在哪里注入的?

error 和 aborted,由框架流式层的 catch 块注入:API 调用抛异常时,按 AbortSignal 是否已触发分别标记为 aborted(用户主动中止)或 error(网络/API 故障)。模型自己不会说这两种状态,是框架替它'兜底',让循环能 fail fast。

用户在 Agent 工作期间又输入了新指令,Pi 的 steering 机制是怎么做到不打断当前 Turn 又能插队的?

steering 在 Turn 边界注入而非打断当前 Turn:内层循环每圈开始调模型前检查 steering 队列,有紧急消息就作为 pending 插进上下文,随下一次模型调用生效。当前 Turn 完整跑完,新指令在下一个 Turn 开头'插队',实现不中断的插队。

04

模型调用:一行代码驾驭多个模型

读全章 →

Pi 为什么用'事件协议 + 函数签名'而不是一个 BaseProvider 抽象类来统一各家模型?

各家 API 差异大到连字段名都不同,抽象基类找不到可复用的共同代码,继承只会逼出空实现。事件协议 + StreamFunction 函数签名只约定输入输出:三个参数进、12 种统一事件出、错误编码进流不 throw——中间怎么实现各家自由,耦合最低。

Anthropic 和 OpenAI 的流式响应解析方式完全不同,翻译器是怎么把两者收敛成同一套事件的?

差异被关在翻译器第 4 步:OpenAI 走 SDK 结构化 chunk 直接取 delta;Anthropic 则自行逐行解析原始 SSE 的 event:/data: 字段并容错处理不完整 JSON。两者最终都映射成同一套 12 种事件(text/thinking/toolcall 各含 start/delta/end,外加 start/done/error),上层消费方式完全一致。

Pi 每圈循环都重建 llmContext 对象,为什么不会破坏 prompt cache?

Anthropic 的 prompt cache 是内容寻址的前缀匹配——看字节不看对象 identity。每圈重建的只是 wrapper 对象,system+tools 内容字节级稳定,cache_control 打点位置固定(system 末尾、最后一个 tool、最后一条 user message),旧前缀持续命中、新消息增量写入,与第几圈调用无关。

05

工具系统:Agent 的手脚是怎么被管住的

读全章 →

模型把数组参数序列化成字符串传过来(如 edits: \"[{...}]\"),Pi 在哪一步、用什么机制修正这类参数怪癖?

在五步管道第 1 步'参数预处理'(prepareArguments)修正——这是专为模型怪癖设计的兼容性垫片:把字符串化的 edits 解析回真正的数组,再交给第 2 步 Schema 验证。它与验证分离是因职责不同:预处理是'知道某模型会犯什么错'的兼容层,验证是'谁调都要验'的安全层。

一批工具调用里有两个 edit 改同一个文件,Pi 怎么防止并行执行互相覆盖?

两道防线:调度层'一票否决'——批中任一工具声明 sequential 则整批串行(精确判断工具冲突太难,宁可多等不可出错);工具内部的 withFileMutationQueue 把同一文件的编辑串行化,实现文件级互斥。即使整批并行,同文件的两个 edit 也不会真正同时写。

工具 execute 抛出未捕获异常时,为什么 Pi 选择把它编码成 isError 消息而不是让异常穿透打断循环?

异常的接收者是调用栈,会打断循环;消息的接收者是模型,能消化错误并自我纠错——read 报'文件不存在'就先 ls 再找对文件名。每种错误的正确下一步只有模型有足够上下文判断。编码成 isError 消息,错误就成为下一步决策的输入,而非放弃模型的全部纠错能力。

06

消息系统:Agent 的记忆如何组织与传递

读全章 →

Pi 内部有 7 种消息类型,但 LLM 只认 3 种——自定义消息(如 BashExecutionMessage)是怎么被 LLM 看到的?

在调用 LLM 前的最后一刻由 convertToLlm 统一翻译:按 role 分派,自定义消息几乎全部转成 user 角色(满足 API 的 user/assistant 交替约束),BashExecutionMessage 的 command/output/exitCode 被格式化成一段扁平文本塞进 UserMessage.content。翻译有损、单向,结构化字段留给 UI 用。

Pi 用 TypeScript 声明合并来扩展 AgentMessage 联合类型,为什么不用继承或泛型?

继承要改基类,但 pi-agent-core 是外部包改不了;泛型要层层传参,每个用到 AgentMessage 的函数签名都被污染。声明合并让核心包只留一个空接口'插槽',应用层注入自己的消息类型——核心包零依赖、扩展包全栈类型安全,不同应用只看到各自注册的类型。

怎么实现'一条消息 UI 能看到、但 LLM 看不到'?Pi 的 excludeFromContext 机制为什么比直接删除消息更好?

excludeFromContext 只让消息在 convertToLlm 时被过滤:消息仍留在 context.messages 里,UI 照常渲染、会话照常持久化、重启后完整恢复。直接删除则 UI 和持久化一起失忆。'对 LLM 隐身'与'存在'被解耦成两个独立维度,还可组合出'仅持久化'等第三级可见性。

07

事件驱动:Agent 的神经系统

读全章 →

Pi 的 Agent Loop 每次 emit 事件都要 await 所有监听器,为什么不走 fire-and-forget?这样设计的代价和收益分别是什么?

收益是状态永远一致:监听器没处理完 message_start,下一个 update 就不会发出,UI 不会渲染空消息或过时内容——await 不是'通知'而是'同步协商',确保所有消费者跟上再走下一步。代价是性能被最慢的消费者拖住。Pi 的选择是正确性优先于吞吐。

tool_execution_update 是高频事件,Pi 没有对它逐条 await——它用了什么折中方案,为什么不违背同步屏障原则?

折中是'先收集、后批量等待':update 的 emit 不逐条 await,Promise 先存进数组,工具结束后 Promise.all 一次等完;acceptingUpdates 闸门丢弃 settle 后的迟到回调。进度事件高频低值可合并,生命周期事件仍逐条等——最终状态一致性不破,因而不违背同步屏障原则。

Pi 的监听器循环里没有 try-catch,一个 UI 渲染 bug 就能把 Agent 干掉——作者为什么故意这样设计?

故意当'保险丝':监听器异常直接冒泡让运行失败,问题立刻可见;若静默吞掉,Agent 看似正常但 UI 已乱,bug 极难定位。设计原则是对内层受信任代码让异常暴露;第三方扩展等不受信任代码则由框架 try-catch 隔离,单个扩展崩溃不拖垮整个会话。

08

上下文工程:让有限窗口装下无限对话

读全章 →

一条 bash 命令输出 8000 行日志,Pi 的截断算法为什么用'行数 + 字节'双限制而不是单限制?保留开头还是末尾由什么决定?

单限制各有盲区:只限行数,单行可能极长(100KB 压缩 JS),三行撑爆体积;只限字节,会把某行拦腰切断破坏结构。行数管可读性、字节管硬体积,先触发者胜。保留方向由信号密度决定:read 保开头(import/类定义密度最高),bash 保末尾(错误堆栈在尾部)。

monorepo 里每层目录都有 CLAUDE.md,Pi 怎么把它们合并进系统提示词?合并顺序为什么是从外到内?

从 cwd 向上递归到根目录,沿途所有 AGENTS.md/CLAUDE.md 全部合并,顺序是:全局(~/.pi)→ 祖先目录 → 当前项目,即最通用在前、最具体在后,并用带 path 属性的 XML 标签包裹。monorepo 里组织/团队/项目规范层层嵌套,从外到内让 LLM 像读分层手册——先总则后细则,且能区分优先级。

Pi 的 Skills 为什么不把全文塞进系统提示词,而是只放清单让 LLM 用 read 工具按需拉取?这种'拉模式'比'推模式'好在哪?

推模式把全部 skill 全文(约 50K token)预付进每个会话,大部分用不上;拉模式只在 prompt 放约 500 token 的清单(name+description+location),任务匹配时 LLM 自己用 read 拉全文——用时才付 token,不用不付。能力库可以无限扩张,而固定上下文开销恒定。

09

上下文压缩:当对话太长怎么办

读全章 →

Pi 用 chars/4 估算 token,这个估算在中文场景下会严重低估——为什么还能用?出了偏差靠什么兜底?

估算是启发式而非账单:英文下 chars/4 足够准;中文会严重低估(1 汉字实际 1–2 token 只算 0.25),预防性压缩可能触发偏晚。这是已知缺陷,兜底靠应急性压缩——一旦 API 真报上下文溢出错误,系统先执行压缩再重试,把估算漏掉的安全边际用补救防线补回来。

压缩历史时切割点为什么不能落在 toolResult 消息上?Pi 允许切在 assistant 上,切断 Turn 的代价用什么机制弥补?

协议硬约束:toolResult 必须紧跟触发它的 assistant,切开就会'调了工具找不到结果',所以合法切点只有 user/assistant。切在 assistant 会切断 Turn(其 user 进压缩区),但能保证压缩在预算内生效;损失由 turnPrefix 弥补——被切断的 Turn 前缀单独生成轻量 3 段摘要,与主摘要并行生成后合并。

长对话被压缩多次后,Pi 怎么避免摘要'每次漂移一点'的累积误差?

靠增量更新而非重写:第二次压缩把上一次摘要作为 previousSummary 传入,LLM 只做更新——已有 Goal/Constraints 原样保留,新增 Progress 追加,而非每次自由重写导致'漂移一点'的累积误差。配合固定 6 section 模板,把'易遗漏'变成'必须填',摘要主干跨多次压缩保持稳定。

10

会话管理:对话的存储、恢复与分叉

读全章 →

Pi 的 Session Tree 节点只有 parentId、没有 children 列表——为什么说'认父不认子'是 append-only 的必要条件?

append-only 要求节点写入后不可修改。若父节点维护 children 列表,每次追加子节点都得改写父节点,直接违反不可变约束。所以只存 parentId 单向引用——写入永远是纯追加 O(1);需要查子节点时用全局 byId 映射反向扫描即可,结构简洁与查询功能两全。

会话切换分支后,旧分支的探索成果怎么让新分支的 LLM 知道?BranchSummaryEntry 和普通消息节点有什么区别?

用 branchWithSummary():把被抛弃分支喂给 LLM 生成结构化摘要(复用压缩的摘要管道),产出 BranchSummaryEntry 挂在分叉点,重建上下文时转成 BranchSummaryMessage 出现在新分支开头。它不是真实对话,而是旧分支的'遗言'——新 Agent 知道'试过 X、结论是 Y'却不被细节淹没。

Pi 把'切换模型'也存成树上的节点而不是全局状态变量,这在回退场景下带来什么好处?

状态节点化让回退天然正确:model_change 节点完整记录'何时、在哪个位置切换',buildSessionContext 沿路径覆盖式提取——回退到切换点之前,路径上不含该节点,model 自动回到切换前的值,无需任何恢复逻辑。若是全局变量,回退后模型设置会停留在'未来',与对话历史错位。

答上来几题?

答不上来的章节,才是真正值得读的部分——每章开头都带着这三个问题去读,效率高得多。

回到首页开始学