逐日AI
第 1 周 · D1约 4 小时

一个 Coding Agent 由哪几层组成:REPL 骨架、命令系统与不绑厂商的网关抽象

先把一个终端 Coding Agent 拆成五层,看清哪一层是自己的、哪一层是厂商的,再动手写出一个能对话的空壳:readline 循环、斜杠命令表、OpenAI 兼容网关 provider 与一套离线剧本,让它在没有余额时也能跑。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能画出一个终端 Coding Agent 的分层图,并说清每一层的输入输出与替换成本
  2. 能手写一个带斜杠命令的 REPL,把命令解析与对话逻辑分开
  3. 能写出一层 OpenAI 兼容的网关抽象,只靠环境变量就在不同厂商与离线剧本之间切换

小白版讲解

我们要造的东西,等于「带一个新人上手一个陌生仓库」

假设今天有个新人入职,你要让他在一个他从没见过的仓库里干活。你会怎么带?

第一天你不会给他任何权限,只是让他坐下来,能跟你说话。第二天你希望他边想边说,而不是憋一小时交一份报告。第三天你给他读代码的权限。第四天才让他动手改,还得能跑测试验证。第五天你们约定:哪些事他自己拍板,哪些要先问你一句。往后是:犯错了怎么纠、下班了怎么交接、他记不住的东西写哪儿、活太多怎么分给别人。

这门课接下来的二十一天,就是把这个过程一天一天写成代码。产出物叫 mca,一个跑在终端里的 Coding Agent。第二十一天它会是一个能全局安装的命令行工具:你在任意仓库里敲一句话,它能读代码、改文件、跑测试、按你的规矩请示、干完还能把改动回滚。

先说清楚这门课的两条边界,免得期待错位。

第一条:全部自己实现。不用 LangChain,不用任何厂商的官方 SDK,不用现成的 MCP 客户端库,连命令行参数解析和终端颜色都自己写。整个项目的运行时依赖只有一个读 .env 的小工具。这不是逞强——一个 Coding Agent 的技术含量恰好全在那些被框架包起来的地方:流式怎么解析、工具调用的参数怎么拼、审批怎么插进循环、上下文满了怎么裁。用框架你会得到一个能跑的东西,但你学不到这些,面试也答不出来。

第二条:每天都要能跑。今天写完这一层,今天就有一条命令能看到现象;而且没有模型密钥也能看到——这一点今天下半段就会兑现。

五层拆解:哪一层是你的,哪一层是厂商的

先看一眼终局的结构。一个终端 Coding Agent 由五层组成,箭头是数据流向:

一行输入 一次运行请求 消息 + 工具清单 一次工具调用 增量分片 工具结果 语义事件 交互层:REPL、命令、渲染、打断 会话层:消息数组、事件日志、恢复与分叉 Agent Loop:一圈圈跑,直到模型不再要工具 网关层:把请求发出去,把增量翻译回来 工具层:读文件、改文件、跑命令
Mermaid 源码
mermaidmermaid
flowchart TD
  A[交互层REPL命令渲染打断] -->|一行输入| B[会话层消息数组事件日志恢复与分叉]
  B -->|一次运行请求| C[Agent Loop一圈圈跑直到模型不再要工具]
  C -->|消息 + 工具清单| D[网关层把请求发出去把增量翻译回来]
  C -->|一次工具调用| E[工具层读文件改文件跑命令]
  D -->|增量分片| C
  E -->|工具结果| C
  C -->|语义事件| A

这张图里最值钱的信息不是「有五层」,而是每一层的输入输出各是什么,因为这决定了你能不能单独替换其中一层。

输入输出替换它的代价
交互层一行文本、一次按键屏幕上的字低。换成 Web 前端不影响下面四层
会话层语义事件落盘的事件日志、可重放的消息数组低。换存储格式只影响它自己
Agent Loop消息数组、工具清单语义事件最高。它同时懂工具、审批、压缩、取消
网关层消息、工具清单增量分片低——前提是它没有渗进循环
工具层一段未解析的参数文本一段回灌给模型的文本低。加一个工具不该动其它任何层

于是有了这门课的第一条设计纪律:厂商的类型不许出现在 Agent Loop 里。循环只认两套你自己定义的类型——网关吐出来的分片、循环对外发出的语义事件——翻译工作全部关在网关层。为什么这么在意?因为循环是二十一天里被改得最多的文件:明天加流式渲染的事件、第三天加工具执行、第五天加审批、第六天加重试与取消、第十二天加压缩。如果它还得跟着厂商报文格式变,你每换一次模型都要在这个最复杂的文件上动刀。

为什么第一天就要写网关抽象:模型 id 的形状不是常量

初学者写 Agent 的第一版代码通常长这样:一个写死的 URL、一个写死的模型名、一段直接调 fetch 的函数。它能跑,然后在第三周崩掉——因为你想换一家更便宜的网关,发现模型名的写法不一样。

这件事具体到什么程度?我在 2026 年 9 月 7 日实测了两家 OpenAI 兼容网关:同一个模型,一家要写成带厂商前缀的形式(例如 openai/gpt-4o-mini),另一家要写上游原名(例如 claude-haiku-4-5-20251001);两家的接口路径、鉴权头、请求体字段全都一样,唯独模型 id 的形状不同。

结论很朴素:base URL、密钥、模型 id 这三样都必须是配置,代码里不许写死,也不许假设它们的格式。所以本课全程只认三个环境变量:

BashBash
LLM_BASE_URL=https://openrouter.ai/api/v1   # 任何 OpenAI 兼容网关
LLM_API_KEY=sk-...                          # 该网关签发的密钥
LLM_MODEL=openai/gpt-4o-mini                # 该网关认的模型 id

再加一个开关 MOCK=1:走离线剧本,不发任何网络请求。二十一天里,这四个变量一个都不会变。

网关层的接口只有一个方法,而且只有流式那一种:

src/providers/types.ts
export type StreamDelta =
  | { type: 'text'; text: string }
  | { type: 'tool_call'; index: number; id?: string; name?: string; argsDelta?: string }
  | { type: 'usage'; promptTokens: number; completionTokens: number; cachedTokens: number }
  | { type: 'finish'; reason: 'stop' | 'tool_calls' | 'length' | 'other' }
 
export interface ChatProvider {
  readonly id: string
  readonly model: string
  stream(req: ChatRequest): AsyncIterable<StreamDelta>
}

为什么不留一个非流式方法?因为 Coding Agent 里没有用得上它的地方——你永远想看到它在想什么。多留一个方法,就是多一处要在二十一天里同步维护的分支。

网关 provider 的最小实现:一次 POST、一段 SSE、一串增量

今天这版故意只解析文本增量——工具调用的分片、半行报文、流中断都留给明天。先把骨架看清楚:

src/providers/gateway.ts
export class GatewayProvider implements ChatProvider {
  readonly id = 'gateway'
 
  constructor(
    private readonly baseUrl: string,
    private readonly apiKey: string,
    readonly model: string
  ) {}
 
  async *stream(req: ChatRequest): AsyncIterable<StreamDelta> {
    const res = await fetch(`${this.baseUrl}/chat/completions`, {
      method: 'POST',
      headers: { authorization: `Bearer ${this.apiKey}`, 'content-type': 'application/json' },
      body: JSON.stringify({
        model: this.model,
        stream: true,
        stream_options: { include_usage: true },
        messages: req.messages.map((m) => ({ role: m.role, content: contentToText(m.content) })),
      }),
      signal: req.signal,
    })
    if (!res.ok || !res.body) {
      const detail = await res.text().catch(() => '')
      throw new Error(`网关返回 ${res.status}:${detail.slice(0, 200)}`)
    }
 
    const decoder = new TextDecoder()
    let buffer = ''
    for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
      buffer += decoder.decode(chunk, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() ?? '' // 最后一段可能是半行,留到下一轮
      for (const line of lines) {
        const trimmed = line.trim()
        if (!trimmed.startsWith('data:')) continue
        const payload = trimmed.slice(5).trim()
        if (payload === '[DONE]') return
        const delta = parseChunk(payload)
        if (delta) yield delta
      }
    }
  }
}

两版之间有个真实差异值得注意:Node 里必须自己留 buffer 处理半行,因为你拿到的是字节块;Python 的 httpx 提供了按行迭代的接口,分帧已经替你做了。这不是语言优劣,是抽象层次不同——明天我们会看到,即使按行分帧也不够,SSE 真正的边界是空行。

斜杠命令为什么不能和对话混在一条 if 里

REPL 的主循环只有三步:读一行、判断是命令还是对话、执行。看起来用两个 if 就能写完。但二十一天后这里会有十几条命令:/help/clear/resume/rewind/compact/context/memory/plan/model……每条都有自己的参数和帮助文本。

所以从第一天起,命令就是数据而不是控制流:一张命令表,每项有名字、帮助文本、执行函数。解析器只负责把一行文本拆成命令名和参数,连「这条命令存不存在」都不管——那是调用方的事。

src/repl.ts
export interface Command {
  name: string
  help: string
  run(args: string, session: Session): Promise<boolean> | boolean // 返回 false 表示退出
}
 
/** 把一行输入解析成命令名与参数;不是命令就返回 null */
export function parseCommand(line: string): { name: string; args: string } | null {
  if (!line.startsWith('/')) return null
  const trimmed = line.slice(1).trim()
  const space = trimmed.indexOf(' ')
  if (space === -1) return { name: trimmed, args: '' }
  return { name: trimmed.slice(0, space), args: trimmed.slice(space + 1).trim() }
}

还有一个坑,是我在写这一天的实验时真踩到的:readline/promisesquestion() 写 REPL,管道喂输入时会挂住。stdin 读到结尾以后,它返回的 promise 在某些 Node 版本里既不成功也不失败,进程最后以退出码 13 结束并报「未结算的顶层 await」。改用 node:readline 的异步迭代器就好了——输入结束时循环自然退出,交互和管道两种用法都对。这件事在这门课里格外重要,因为后面每天的验收都要用管道喂输入。

离线剧本 provider:让接下来二十天的现象都能免费复现

现在写第二个 provider。它不发网络请求,按剧本吐分片。

关键在于剧本的质量。「返回一句写死的假回复」只能证明程序没崩;有用的剧本是这样的:先说两句话,然后要求调用某个工具,拿到工具结果后再说结论。于是离线模式下,Agent 会真的去读文件、真的改文件、真的跑测试——只有「模型说了什么」是假的,其余全是真的。

src/providers/mock.ts
const CHUNK_SIZE = 4
const CHUNK_DELAY_MS = 12
 
export class MockProvider implements ChatProvider {
  readonly id = 'mock'
  constructor(readonly model: string) {}
 
  async *stream(req: ChatRequest): AsyncIterable<StreamDelta> {
    // 按最后一条用户消息选分支,不按轮数递增——否则注入故障后剧本就错位了
    const lastUser = [...req.messages].reverse().find((m) => m.role === 'user')
    const scene = pickScene(contentToText(lastUser?.content ?? ''))
 
    for (const piece of splitText(scene.text)) {
      await sleep(CHUNK_DELAY_MS)
      yield { type: 'text', text: piece }
    }
    yield { type: 'usage', promptTokens: 0, completionTokens: estimateTokens(scene.text), cachedTokens: 0 }
    yield { type: 'finish', reason: 'stop' }
  }
}

注意最后两条事件:用量和结束原因。少了它们,渲染层看不到用量、循环也不知道该收尾——离线和真实两条路径的事件序列必须一样,否则你在离线下调好的逻辑,一上真模型就露馅。

这也是为什么桩要打在自己的接口上,而不是打在 fetch 上:打在 fetch 上,你桩的是报文格式,得跟着每家网关变;打在自己的 provider 接口上,你桩的是语义,二十天不用动。

今天故意不做流式渲染

最后一件事,是留一个体感。今天的渲染层会等一轮结束、再把整段回答一次性打出来,末尾附一行统计:收到几个增量、首字延迟多少、总共多久。

为什么明知道更好的做法却不做?因为下面这两行实测数据(2026 年 9 月 7 日,同一家网关,同一个问题)比任何解释都有说服力:

TextText
模型 A:首字 1832ms,总计 2147ms,6 个增量
模型 B:首字 10978ms,总计 11010ms,3 个增量

模型 B 的总耗时并不比 A 差十倍,它只是在开口前先想了很久。在「攒完再打」的实现里,这十一秒是一个完全静默的黑屏;换成流式,用户在第二秒就看到字在动。流式不是体验糖,它是首字延迟的唯一解法——这就是明天第一件事要做的东西。

上面这两行里,增量个数是可复现的(同样的问题问同一个模型,量级稳定),耗时不可复现(取决于你的网络与当时的负载),看的时候只看相对关系。

源码导读

动手实验

🧪 D1 实验:REPL 空壳与网关 provider

代码位置:labs/my-coding-agent-21days/day-01-repl-skeleton

  1. 把输入分成两条路径:斜杠命令走命令表,其余走对话。命令表里先放 /help/clear/seed/model/exit 五条。
  2. 定义消息、工具、事件三套类型,以及只有一个流式方法的网关接口。今天用不到工具类型也要先定下来——它们是后面二十天的地基,写完就冻结。
  3. 实现网关 provider:手写请求体,从环境变量取 base URL、密钥与模型 id,把 SSE 解析成文本增量。
  4. 实现离线剧本 provider,让 MOCK=1 时不发任何网络请求,并且事件序列与真实路径一致。
  5. 写自检入口:MOCK=1 SELFTEST=1 pnpm start 逐项打印勾叉,证明沙盒仓库、命令分流、离线流式、网关切换四件事都成立。

验收看四条勾:自检打印 4/4 通过;管道喂 /help 能看到命令表;随便问一句能看到回答且增量个数大于一;只改两个环境变量就能换网关。

面试题

今天三道题,都在考「有没有真的自己写过一个」,而不是「知不知道 Agent 是什么」:

  1. 把一个终端 Coding Agent 分层,你会怎么切?哪一层最不该让厂商 SDK 渗进来?
  2. 模型厂商都提供了官方 SDK,为什么还要自己写一层网关抽象?什么时候这层是负担?
  3. 一个重度依赖付费模型 API 的命令行工具,怎么做到没有密钥也能开发和测试?

完整的中英题干、分析过程与答题要点见本课面试题库的第一天。第二题和第三题是这门课最常被追问的两道——前者考抽象的位置,后者考工程习惯,两道题的答案都要能落到具体文件上,不能停在原则层面。

检查清单与明日预告

  • 能画出五层结构,并说出每一层的输入输出与替换代价
  • 知道为什么厂商类型不许进 Agent Loop,以及翻译该放在哪一层
  • 三个环境变量加一个离线开关,能说清各自的作用
  • 命令表是数据不是 if 链,解析器只负责拆不负责判断存在
  • 离线剧本与真实网关的事件序列一致,包括末尾的用量与结束原因
  • 自检入口能跑出 4/4 通过,并且知道为什么不能只看退出码

明天是 D2《流式输出与终端渲染:手写 SSE 解析、增量 Markdown 与可打断的打字机》:把今天「攒完再打」的黑屏体验拆掉。我们会把 SSE 解析器写成正式版本(按空行分帧、处理半行报文与流中断),把网关分片翻译成语义事件,然后在终端里做出打字机式的增量渲染,并让它能被随时打断。今天留下的那十一秒静默,明天会变成第二秒就开始动的字。

面试题库

  • 把一个终端 Coding Agent 分层,你会怎么切?哪一层最不该让厂商 SDK 渗进来?How would you layer a terminal coding agent, and which layer must never be polluted by a vendor SDK?
    国内高频海外高频基础#architecture#agent-loop

    分析过程 · 先想清楚再作答

    1. 这题在筛「有没有自己写过一个」。只答「有个循环调模型再调工具」的人,说的是所有人都会说的一句话;区分度在于你能不能说出每一层的输入输出,以及换掉某一层要付多少代价。
    2. 怎么拆:按「谁依赖谁」自下往上数。网关层的输入是消息与工具清单、输出是增量分片;工具层的输入是一段未解析的 JSON 文本、输出是一段回灌文本;循环层把这两者接起来,输出是语义事件;会话层把事件落盘并能重放;交互层只订阅事件,负责渲染与打断。五层,每一层的输入输出都能一句话说清,说不清就是切错了。
    3. 接着答第二问:最不该被渗透的是循环层。厂商 SDK 的类型一旦出现在循环里,换厂商就要改循环,而循环是唯一同时懂工具、审批、压缩、取消的地方,改它的成本最高。做法是循环只认自己定义的两套类型——网关吐出来的分片形状,和循环对外发出的语义事件——网关层负责在这两者之间翻译。
    4. 结论:分层的判据不是「看起来整齐」,是「能不能单独替换」。渲染层能换成 Web 前端而不动循环、网关层能从一家换到另一家而不动工具、工具层能加一个工具而不动渲染,三条都成立,分层才是真的。
    5. 可预期的追问:那渲染层为什么不能直接读网关的分片?因为工具调用与审批不是网关分片能表达的东西。渲染要显示「正在读文件」这张卡片,它订阅的必须是语义事件;直接读分片会让渲染层跟着每家网关的报文格式变。

    How to reason about it · think before answering

    1. This screens for having actually built one. Saying there is a loop that calls the model and then tools is what everyone says; the signal is naming each layer's inputs and outputs and the cost of replacing it.
    2. How to break it down: count bottom-up by dependency. The gateway layer takes messages plus a tool list and emits deltas; the tool layer takes an unparsed JSON string and returns text to feed back; the loop wires the two and emits semantic events; the session layer persists those events and can replay them; the interface layer subscribes to events only, for rendering and interruption. Five layers, each with inputs and outputs you can state in one sentence.
    3. Then the second half: the loop must stay clean. Once vendor SDK types appear inside it, swapping vendors means editing the loop, and the loop is the one place that knows about tools, approval, compaction and cancellation, so it is the most expensive file to touch. The fix is that the loop only knows two of your own types, and the gateway layer translates between them.
    4. Conclusion: the test for a layering is not tidiness, it is independent replaceability. The renderer can become a web UI without touching the loop, the gateway can move to another vendor without touching tools, and a new tool can be added without touching rendering.
    5. Likely follow-up: why can't the renderer read gateway deltas directly? Because tool calls and approvals are not expressible as gateway deltas. Rendering a reading-file card requires semantic events; reading raw deltas couples the UI to every vendor's wire format.

    答题要点

    • 五层各说清输入输出:网关、工具、循环、会话、交互
    • 最不该被渗透的是循环层,因为它是唯一同时懂工具、审批、压缩、取消的地方
    • 循环只认两套自己的类型:网关分片与语义事件,翻译放在网关层
    • 分层的判据是能不能单独替换,而不是看起来整齐
    • 渲染层订阅语义事件而不是网关分片,否则会跟着报文格式变

    Key points

    • State inputs and outputs for five layers: gateway, tools, loop, session, interface
    • The loop must stay vendor-free because it is the only place that knows tools, approval, compaction and cancellation
    • The loop knows only two of your own types: gateway deltas and semantic events; translation lives in the gateway layer
    • The test of a layering is independent replaceability, not tidiness
    • The renderer subscribes to semantic events, not gateway deltas, or it tracks every wire format
  • 模型厂商都提供了官方 SDK,为什么还要自己写一层网关抽象?什么时候这层是负担?Vendors ship official SDKs, so why write your own gateway layer? When does that layer become a liability?
    国内高频海外高频进阶#provider-abstraction#architecture

    分析过程 · 先想清楚再作答

    1. 这题看的是你有没有真的换过一次模型。答「解耦」拿不到分,要给出「这层买到了什么、赔上了什么」的具体清单。
    2. 怎么拆:先问「不写这层会散掉什么」。三样,而且都能落到具体文件上。第一,离线可跑——网络出口收敛到一处才可能整体打桩,测试与 CI 才能不花钱地跑完整循环。第二,模型 id 不再是常量——同一个模型在不同网关上叫不同名字,有的要带厂商前缀有的不带,写死在代码里就换不动。第三,计量收口——每次调用的 token 与花费必须有唯一一处记账,否则后面算成本要满仓库找调用点。
    3. 接着讲抽象放在哪:接口按业务动作定义,不按 HTTP 请求定义。它只有一个方法、只有流式那一种,因为非流式在 Coding Agent 里没有用处;多留一个方法就多一处要在整个项目里维护的分支。
    4. 结论与代价:这层会磨掉各家的独有能力,比如某家的思考块、某家的缓存控制字段。正确处理不是把接口撑大,而是留一个可选透传字段,让需要它的那一处显式承认自己绑定了某一家。
    5. 什么时候是负担:只用一家、也永远不需要离线跑的时候;以及出现两个信号时——为了加一家网关改了接口签名、或者接口里出现了只有一家有的参数名。这两个信号说明抽象抽在了厂商能力的最小公倍数上,位置错了。
    6. 可预期的追问:直接用聚合网关不就行了?聚合网关解决协议差异,解决不了你自己的事件契约、落盘格式与计量口径,而这三样才是你后面十几天要反复用的东西。

    How to reason about it · think before answering

    1. This checks whether you have actually swapped models. Answering decoupling scores nothing; give a concrete list of what the layer buys and what it costs.
    2. How to break it down: ask what falls apart without it. Three things, each traceable to a file. First, offline runnability, because only when network egress is funneled into one place can you stub the whole thing. Second, the model id stops being a constant, since the same model has different names on different gateways. Third, metering, because tokens and cost per call must be recorded in exactly one place.
    3. Then place the abstraction: define it by business action, not by HTTP request. One method, streaming only, because non-streaming has no use in a coding agent, and every extra method is another branch to maintain.
    4. Conclusion and cost: the layer sands off vendor-specific features such as thinking blocks or cache-control fields. The fix is not a wider interface but one optional passthrough field, so a single call site explicitly admits it is vendor-bound.
    5. When it is a liability: one vendor forever and no offline path; plus two warning signs, namely adding a gateway forced a signature change, or a vendor-only parameter name leaked into the interface.
    6. Likely follow-up: why not just use an aggregation gateway? It normalizes protocols but not your event contract, on-disk format, or metering, and those are what the next weeks of work depend on.

    答题要点

    • 三个具体收益:离线可跑、模型 id 变成配置、计量收口到一处
    • 接口按业务动作定义,只留流式一个方法,非流式在 Coding Agent 里没用处
    • 代价是磨掉厂商独有能力,用可选透传字段处理而不是撑大接口
    • 两个抽错了的信号:加网关要改签名、接口里出现厂商专有参数
    • 聚合网关解决协议差异,解决不了自己的事件契约与计量口径

    Key points

    • Three concrete gains: offline runnability, model id as configuration, and a single metering point
    • Define the interface by business action with a single streaming method; non-streaming is useless here
    • The cost is losing vendor-specific features; handle it with an optional passthrough field, not a fatter interface
    • Two signs you abstracted wrong: adding a gateway changes the signature, or a vendor-only parameter leaks in
    • Aggregation gateways normalize protocols, not your event contract or metering
  • 一个重度依赖付费模型 API 的命令行工具,怎么做到没有密钥也能开发和测试?How do you make a CLI that leans heavily on a paid model API developable and testable without any API key?
    国内高频海外高频进阶#testing#developer-experience

    分析过程 · 先想清楚再作答

    1. 这题在考工程习惯,不在考技巧。答「写单元测试 mock 掉 fetch」的人只解决了测试,没解决开发;区分度在于你能不能让整条链路在离线状态下产生真实现象。
    2. 怎么拆:先定打桩位置。桩要打在自己的接口上,不是打在 fetch 或 HTTP 层——打在 fetch 上你桩的是报文,得跟着每家网关的格式改;打在自己的 provider 接口上,桩的是语义,二十天都不用动。
    3. 然后是桩的质量。假回复一句话的桩只能证明程序没崩;有用的桩是「按剧本吐分片」——先说两句话、再请求调某个工具、拿到结果后再说结论,于是离线也会真的读文件、真的改文件、真的跑测试。桩的分支要按最后一条用户消息与已有工具结果选,不能按轮数递增,否则注入故障后剧本就错位。
    4. 结论:离线不是省钱,是让「现象可复现」。真实模型每次输出都不一样,教学、回归测试与 CI 都需要一个确定的现象;而且离线模式还顺带成了故障注入的入口——限流、超时、非法参数、流中断都可以在这一层制造。
    5. 可预期的追问:怎么保证桩不和真实行为偏离?两条。一是桩与真实实现共用同一个接口与同一套类型,编译期就挡住形状漂移;二是留一个用真实密钥跑的验证脚本,把「真实响应长什么样」定期核一遍,偏差立刻改桩。

    How to reason about it · think before answering

    1. This probes engineering habits, not tricks. Answering mock out fetch in unit tests solves testing but not development; the signal is making the whole chain produce real behavior offline.
    2. How to break it down: decide where the stub goes. Stub your own interface, not fetch or HTTP. Stubbing fetch means stubbing wire format, which must track every vendor; stubbing your provider interface means stubbing semantics, which stays stable for weeks.
    3. Then stub quality. A canned one-liner only proves the process did not crash. A useful stub follows a script, so offline runs really read files, really edit them, really run tests. Script branches must be chosen by the last user message and existing tool results, never by turn counter, or injected failures desynchronize the script.
    4. Conclusion: offline mode is not about saving money, it is about reproducible behavior. Real models differ every run, while teaching, regression tests and CI all need a deterministic phenomenon. It also becomes the natural injection point for failures.
    5. Likely follow-up: how do you keep the stub from drifting from reality? The stub and the real implementation share one interface and one set of types, so shape drift fails at compile time; and keep a verification script that runs against a real key periodically.

    答题要点

    • 桩打在自己的 provider 接口上,不打在 fetch 或 HTTP 层
    • 桩要按剧本吐分片,让离线也真的调工具、改文件、跑测试
    • 剧本按最后一条用户消息与已有工具结果选分支,不按轮数递增
    • 离线的真正价值是现象可复现,并顺带成为故障注入的入口
    • 防漂移两招:桩与真实实现共用类型;留一个用真实密钥的验证脚本定期核对

    Key points

    • Stub your own provider interface, not fetch or the HTTP layer
    • Make the stub emit scripted deltas so offline runs really call tools, edit files and run tests
    • Choose script branches by the last user message and existing tool results, not a turn counter
    • The real value of offline mode is reproducible behavior, and it doubles as the failure-injection point
    • Prevent drift by sharing types with the real implementation and keeping a real-key verification script

评论