What Layers Make Up a Coding Agent: the REPL Skeleton, the Command System, and a Provider-Neutral Gateway Abstraction
First split a terminal Coding Agent into five layers, see which layer is yours and which belongs to the provider, then build a working conversational shell: a readline loop, a slash-command table, an OpenAI-compatible gateway provider, and an offline script — so it still runs with no balance left.
Today's Goals
- Draw a terminal Coding Agent's layer diagram, and explain each layer's inputs, outputs, and cost to replace
- Hand-write a REPL with slash commands, separating command parsing from conversation logic
- Write an OpenAI-compatible gateway abstraction that switches between different providers and an offline script using only environment variables
Plain-Language Walkthrough
What we are building equals onboarding a new hire onto an unfamiliar repo
Suppose a new hire starts today and you need them working in a repository they have never seen. How do you onboard them?
On day one you give them no permissions at all, just a seat and the ability to talk to you. On day two you want them thinking out loud rather than disappearing for an hour and handing you a report. On day three you grant read access to the code. Only on day four do they start editing, and they must be able to run the tests to check themselves. On day five you agree on which calls they make alone and which need asking first. After that: how to correct a mistake, how to hand over at the end of a shift, where to write down what they cannot remember, how to split work when there is too much of it.
The next twenty-one days write that process into code one day at a time. The artifact is called mca, a Coding Agent that runs in your terminal. On day twenty-one it is a globally installable command-line tool: type a sentence in any repository and it reads code, edits files, runs tests, asks permission by your rules, and can roll its changes back afterwards.
Two boundaries first, so expectations line up.
One: everything is implemented by hand. No LangChain, no vendor SDK, no off-the-shelf MCP client library — even command-line argument parsing and terminal colors are written from scratch. The project's only runtime dependency is a small tool that reads .env. This is not showing off: a Coding Agent's substance lies exactly in the parts frameworks hide — how streaming is parsed, how tool-call arguments are assembled, how approval is inserted into the loop, how a full context is trimmed. A framework gets you something that runs, and you learn none of that, and cannot answer for it in an interview.
Two: it must run every day. Whatever layer you finish today has a command that shows you something today — and without a model key too, which the second half of today delivers.
Five layers: which are yours and which belong to the provider
Look at the end state first. A terminal Coding Agent has five layers, and the arrows are data flow:
Mermaid source
flowchart TD
A[interaction: REPL, commands, rendering, interruption] -->|one line of input| B[session: message array, event log, resume and fork]
B -->|one run request| C[Agent Loop: iterate until the model stops asking for tools]
C -->|messages plus tool list| D[gateway: send the request, translate the deltas back]
C -->|one tool call| E[tools: read files, edit files, run commands]
D -->|stream deltas| C
E -->|tool results| C
C -->|semantic events| AThe valuable information in that diagram is not "there are five layers" but each layer's inputs and outputs, because that is what decides whether you can replace one layer alone.
| Layer | Input | Output | Cost to replace |
|---|---|---|---|
| Interaction | a line of text, a keypress | characters on screen | Low. Swapping in a web frontend leaves the other four untouched |
| Session | semantic events | a persisted event log, a replayable message array | Low. Changing storage format affects only itself |
| Agent Loop | message array, tool list | semantic events | Highest. It knows tools, approval, compaction and cancellation at once |
| Gateway | messages, tool list | stream deltas | Low — provided it has not leaked into the loop |
| Tools | one string of unparsed arguments | one string fed back to the model | Low. Adding a tool should touch no other layer |
Hence this course's first design rule: no provider type may appear inside the Agent Loop. The loop knows only two type families you defined yourself — the deltas the gateway emits and the semantic events the loop emits — with all translation sealed inside the gateway layer. Why care so much? Because the loop is the most-edited file of the twenty-one days: streaming render events tomorrow, tool execution on day three, approval on day five, retry and cancellation on day six, compaction on day twelve. If it also has to track provider wire formats, every model switch means surgery on your most complex file.
Why the gateway abstraction belongs on day one: a model id's shape is not a constant
A beginner's first Agent code usually looks like this: a hard-coded URL, a hard-coded model name, and a function calling fetch directly. It runs, and then it collapses in week three — because you want to switch to a cheaper gateway and find the model name is written differently.
How concretely? On 7 September 2026 I measured two OpenAI-compatible gateways: for the same model, one wants a vendor-prefixed form (for example openai/gpt-4o-mini) and the other wants the upstream name (for example claude-haiku-4-5-20251001). Their endpoint paths, auth headers and request body fields are all identical; only the shape of the model id differs.
The conclusion is plain: base URL, key and model id must all be configuration, never hard-coded and never assumed to have a particular format. So this whole course knows exactly three environment variables:
LLM_BASE_URL=https://openrouter.ai/api/v1 # any OpenAI-compatible gateway
LLM_API_KEY=sk-... # a key issued by that gateway
LLM_MODEL=openai/gpt-4o-mini # a model id that gateway acceptsPlus one switch, MOCK=1: run the offline script and send no network requests. Across twenty-one days, none of those four changes.
The gateway layer's interface has exactly one method, and only the streaming kind:
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>
}from dataclasses import dataclass
from typing import AsyncIterator, Literal, Protocol
@dataclass
class TextDelta:
text: str
@dataclass
class ToolCallDelta:
index: int
id: str | None = None
name: str | None = None
args_delta: str | None = None
@dataclass
class UsageDelta:
prompt_tokens: int
completion_tokens: int
cached_tokens: int
@dataclass
class FinishDelta:
reason: Literal["stop", "tool_calls", "length", "other"]
StreamDelta = TextDelta | ToolCallDelta | UsageDelta | FinishDelta
class ChatProvider(Protocol):
id: str
model: str
def stream(self, req: "ChatRequest") -> AsyncIterator[StreamDelta]: ...Why no non-streaming method? Because nothing in a Coding Agent uses one — you always want to see what it is thinking. An extra method is one more branch to keep in sync for twenty-one days.
The gateway provider's minimal implementation: one POST, one SSE stream, a run of deltas
Today's version deliberately parses text deltas only — tool-call fragments, half lines and stream interruptions are tomorrow's. Get the skeleton clear first:
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(`gateway returned ${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() ?? '' // the tail may be half a line; keep it for the next round
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
}
}
}
}import json
import httpx
class GatewayProvider:
id = "gateway"
def __init__(self, base_url: str, api_key: str, model: str) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
async def stream(self, req: "ChatRequest"):
payload = {
"model": self.model,
"stream": True,
"stream_options": {"include_usage": True},
"messages": [
{"role": m.role, "content": content_to_text(m.content)} for m in req.messages
],
}
headers = {"authorization": f"Bearer {self.api_key}"}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST", f"{self.base_url}/chat/completions", json=payload, headers=headers
) as res:
if res.status_code >= 400:
detail = (await res.aread()).decode()[:200]
raise RuntimeError(f"gateway returned {res.status_code}: {detail}")
# httpx's aiter_lines already handles framing, so no buffer is needed here
async for line in res.aiter_lines():
line = line.strip()
if not line.startswith("data:"):
continue
payload_text = line[5:].strip()
if payload_text == "[DONE]":
return
delta = parse_chunk(json.loads(payload_text))
if delta is not None:
yield deltaOne real difference between the versions is worth noting: in Node you must keep a buffer for half lines, because you receive byte chunks; Python's httpx offers line-wise iteration and has done the framing for you. That is not language superiority but a different level of abstraction — and tomorrow we will see that even line framing is not enough, because SSE's real boundary is the blank line.
Why slash commands must not share one if-statement with conversation
The REPL's main loop has three steps: read a line, decide whether it is a command or conversation, execute. It looks like two ifs. But twenty-one days later there will be a dozen commands: /help, /clear, /resume, /rewind, /compact, /context, /memory, /plan, /model and more, each with its own arguments and help text.
So from day one, commands are data, not control flow: a command table where each entry has a name, help text and a run function. The parser only splits a line into a command name and arguments; it does not even check whether the command exists — that is the caller's job.
export interface Command {
name: string
help: string
run(args: string, session: Session): Promise<boolean> | boolean // false means exit
}
/** Parse one line into a command name and arguments; return null if it is not a command */
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() }
}from dataclasses import dataclass
from typing import Awaitable, Callable
@dataclass
class Command:
name: str
help: str
run: Callable[[str, "Session"], Awaitable[bool] | bool] # False means exit
def parse_command(line: str) -> tuple[str, str] | None:
"""Parse one line into a command name and arguments; return None if not a command"""
if not line.startswith("/"):
return None
name, _, args = line[1:].strip().partition(" ")
return name, args.strip()And one trap I hit while writing this day's lab: writing the REPL with question() from readline/promises hangs when input is piped in. Once stdin reaches its end, the promise it returns neither resolves nor rejects on some Node versions, and the process finally exits with code 13 complaining about an unsettled top-level await. Switching to node:readline's async iterator fixes it — the loop ends naturally when input ends, and both interactive and piped usage work. That matters especially in this course, because every day's acceptance pipes input in.
The offline script provider: reproduce the next twenty days' behavior for free
Now write the second provider. It sends no network requests and emits deltas from a script.
What matters is the script's quality. "Return one hard-coded fake reply" only proves the program did not crash; a useful script goes: say two sentences, then ask to call a tool, then draw a conclusion from the tool's result. So offline, the Agent really reads files, really edits files, really runs tests — only "what the model said" is fake, and everything else is real.
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> {
// Branch on the last user message, not on a turn counter - otherwise an injected
// failure shifts the script out of alignment
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' }
}
}import asyncio
CHUNK_SIZE = 4
CHUNK_DELAY = 0.012
class MockProvider:
id = "mock"
def __init__(self, model: str) -> None:
self.model = model
async def stream(self, req: "ChatRequest"):
# Branch on the last user message, not on a turn counter
last_user = next((m for m in reversed(req.messages) if m.role == "user"), None)
scene = pick_scene(content_to_text(last_user.content) if last_user else "")
for i in range(0, len(scene.text), CHUNK_SIZE):
await asyncio.sleep(CHUNK_DELAY)
yield TextDelta(scene.text[i : i + CHUNK_SIZE])
yield UsageDelta(0, estimate_tokens(scene.text), 0)
yield FinishDelta("stop")Note the last two events: usage and finish reason. Without them the render layer sees no usage and the loop does not know to wrap up — the offline and real paths must emit the same event sequence, or logic you tuned offline falls apart the moment a real model is attached.
That is also why the stub sits on your own interface rather than on fetch: stubbing fetch stubs a wire format that changes with every gateway; stubbing your own provider interface stubs semantics, which do not move for twenty days.
Deliberately no streaming rendering today
One last thing, to leave you with a feeling. Today's render layer waits for the turn to end and prints the whole answer at once, followed by a line of statistics: how many deltas arrived, the time to first token, the total time.
Why skip the better approach knowing it exists? Because these two measured lines (7 September 2026, same gateway, same question) argue better than any explanation:
model A: first token 1832ms, total 2147ms, 6 deltas
model B: first token 10978ms, total 11010ms, 3 deltasModel B's total is not ten times worse than A's; it simply thought for a long time before speaking. In the "buffer then print" implementation those eleven seconds are a completely silent black screen; with streaming, the user sees characters moving in the second second. Streaming is not a nicety, it is the only remedy for time to first token — and it is the first thing we build tomorrow.
In those two lines the delta count is reproducible (ask the same model the same question and the magnitude holds), and the timings are not (they depend on your network and the load at that moment), so read them only for the relative relationship.
Source Reading
- Anthropic: Building effective agents (the original argument for loops and tools): the first-hand source for the judgment that an Agent is a loop with tools. Today you only need its distinction between tool loops and workflows — twenty-one days here build the former, in a form where every layer can be replaced.
- OpenRouter docs: the OpenAI-compatible API and model id naming: compare the request body fields and the model id naming rules, and note that its model ids carry a vendor prefix.
- n1n docs: another OpenAI-compatible gateway, for comparing base URL and model id differences: read it side by side with the previous entry and you see the same endpoint and the same auth header with a different model id form — the practical basis for the rule that a model id must be configuration.
Hands-On Lab
- Split input into two paths: slash commands go to the command table, everything else to conversation. Start the table with
/help,/clear,/seed,/modeland/exit. - Define the three type families — messages, tools, events — plus the gateway interface with its single streaming method. Define the tool types even though today does not use them: they are the foundation for the next twenty days, and they freeze once written.
- Implement the gateway provider: hand-write the request body, take base URL, key and model id from the environment, and parse SSE into text deltas.
- Implement the offline script provider so
MOCK=1sends no network requests and emits the same event sequence as the real path. - Write the self-test entry point:
MOCK=1 SELFTEST=1 pnpm startprints a pass or fail per item, proving the sandbox repo, command routing, offline streaming and gateway switching all work.
Acceptance is four ticks: the self-test prints 4/4 passed; piping /help shows the command table; any question produces an answer with more than one delta; and changing two environment variables switches gateways.
Interview Questions
Today's three questions all test whether you have actually written one, not whether you know what an Agent is:
- How would you split a terminal Coding Agent into layers? Which layer must a vendor SDK never leak into?
- Vendors all provide official SDKs, so why write your own gateway abstraction? When does that layer become a burden?
- For a command-line tool that depends heavily on a paid model API, how do you make development and testing possible without a key?
The full bilingual prompts, analyses and key points are in this course's day-one question bank. Questions two and three are the most frequently probed in this course — the first tests where an abstraction belongs, the second tests engineering habits, and both answers must land on concrete files rather than stopping at principles.
Checklist and Tomorrow
- I can draw the five layers and state each one's inputs, outputs and replacement cost
- I know why provider types must not enter the Agent Loop, and which layer the translation belongs to
- I can explain the roles of the three environment variables plus the offline switch
- The command table is data rather than an if-chain, and the parser splits without checking existence
- The offline script and the real gateway emit the same event sequence, including the trailing usage and finish reason
- The self-test entry point prints 4/4 passed, and I know why an exit code alone is not enough
Tomorrow is D2, "Streaming Output and Terminal Rendering: Hand-Written SSE Parsing, Incremental Markdown, and an Interruptible Typewriter": we dismantle today's buffer-then-print black screen. We write the SSE parser in its full form (framing by blank lines, handling half lines and stream interruptions), translate gateway deltas into semantic events, build incremental typewriter rendering in the terminal, and make it interruptible at any moment. Today's eleven silent seconds become characters moving in the second second.
Interview questions
How would you layer a terminal coding agent, and which layer must never be polluted by a vendor SDK?把一个终端 Coding Agent 分层,你会怎么切?哪一层最不该让厂商 SDK 渗进来?
Common in ChinaCommon overseasBasic#architecture#agent-loopHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 这题在筛「有没有自己写过一个」。只答「有个循环调模型再调工具」的人,说的是所有人都会说的一句话;区分度在于你能不能说出每一层的输入输出,以及换掉某一层要付多少代价。
- 怎么拆:按「谁依赖谁」自下往上数。网关层的输入是消息与工具清单、输出是增量分片;工具层的输入是一段未解析的 JSON 文本、输出是一段回灌文本;循环层把这两者接起来,输出是语义事件;会话层把事件落盘并能重放;交互层只订阅事件,负责渲染与打断。五层,每一层的输入输出都能一句话说清,说不清就是切错了。
- 接着答第二问:最不该被渗透的是循环层。厂商 SDK 的类型一旦出现在循环里,换厂商就要改循环,而循环是唯一同时懂工具、审批、压缩、取消的地方,改它的成本最高。做法是循环只认自己定义的两套类型——网关吐出来的分片形状,和循环对外发出的语义事件——网关层负责在这两者之间翻译。
- 结论:分层的判据不是「看起来整齐」,是「能不能单独替换」。渲染层能换成 Web 前端而不动循环、网关层能从一家换到另一家而不动工具、工具层能加一个工具而不动渲染,三条都成立,分层才是真的。
- 可预期的追问:那渲染层为什么不能直接读网关的分片?因为工具调用与审批不是网关分片能表达的东西。渲染要显示「正在读文件」这张卡片,它订阅的必须是语义事件;直接读分片会让渲染层跟着每家网关的报文格式变。
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
答题要点
- 五层各说清输入输出:网关、工具、循环、会话、交互
- 最不该被渗透的是循环层,因为它是唯一同时懂工具、审批、压缩、取消的地方
- 循环只认两套自己的类型:网关分片与语义事件,翻译放在网关层
- 分层的判据是能不能单独替换,而不是看起来整齐
- 渲染层订阅语义事件而不是网关分片,否则会跟着报文格式变
Vendors ship official SDKs, so why write your own gateway layer? When does that layer become a liability?模型厂商都提供了官方 SDK,为什么还要自己写一层网关抽象?什么时候这层是负担?
Common in ChinaCommon overseasIntermediate#provider-abstraction#architectureHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 这题看的是你有没有真的换过一次模型。答「解耦」拿不到分,要给出「这层买到了什么、赔上了什么」的具体清单。
- 怎么拆:先问「不写这层会散掉什么」。三样,而且都能落到具体文件上。第一,离线可跑——网络出口收敛到一处才可能整体打桩,测试与 CI 才能不花钱地跑完整循环。第二,模型 id 不再是常量——同一个模型在不同网关上叫不同名字,有的要带厂商前缀有的不带,写死在代码里就换不动。第三,计量收口——每次调用的 token 与花费必须有唯一一处记账,否则后面算成本要满仓库找调用点。
- 接着讲抽象放在哪:接口按业务动作定义,不按 HTTP 请求定义。它只有一个方法、只有流式那一种,因为非流式在 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
答题要点
- 三个具体收益:离线可跑、模型 id 变成配置、计量收口到一处
- 接口按业务动作定义,只留流式一个方法,非流式在 Coding Agent 里没用处
- 代价是磨掉厂商独有能力,用可选透传字段处理而不是撑大接口
- 两个抽错了的信号:加网关要改签名、接口里出现厂商专有参数
- 聚合网关解决协议差异,解决不了自己的事件契约与计量口径
How do you make a CLI that leans heavily on a paid model API developable and testable without any API key?一个重度依赖付费模型 API 的命令行工具,怎么做到没有密钥也能开发和测试?
Common in ChinaCommon overseasIntermediate#testing#developer-experienceHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 这题在考工程习惯,不在考技巧。答「写单元测试 mock 掉 fetch」的人只解决了测试,没解决开发;区分度在于你能不能让整条链路在离线状态下产生真实现象。
- 怎么拆:先定打桩位置。桩要打在自己的接口上,不是打在 fetch 或 HTTP 层——打在 fetch 上你桩的是报文,得跟着每家网关的格式改;打在自己的 provider 接口上,桩的是语义,二十天都不用动。
- 然后是桩的质量。假回复一句话的桩只能证明程序没崩;有用的桩是「按剧本吐分片」——先说两句话、再请求调某个工具、拿到结果后再说结论,于是离线也会真的读文件、真的改文件、真的跑测试。桩的分支要按最后一条用户消息与已有工具结果选,不能按轮数递增,否则注入故障后剧本就错位。
- 结论:离线不是省钱,是让「现象可复现」。真实模型每次输出都不一样,教学、回归测试与 CI 都需要一个确定的现象;而且离线模式还顺带成了故障注入的入口——限流、超时、非法参数、流中断都可以在这一层制造。
- 可预期的追问:怎么保证桩不和真实行为偏离?两条。一是桩与真实实现共用同一个接口与同一套类型,编译期就挡住形状漂移;二是留一个用真实密钥跑的验证脚本,把「真实响应长什么样」定期核一遍,偏差立刻改桩。
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
答题要点
- 桩打在自己的 provider 接口上,不打在 fetch 或 HTTP 层
- 桩要按剧本吐分片,让离线也真的调工具、改文件、跑测试
- 剧本按最后一条用户消息与已有工具结果选分支,不按轮数递增
- 离线的真正价值是现象可复现,并顺带成为故障注入的入口
- 防漂移两招:桩与真实实现共用类型;留一个用真实密钥的验证脚本定期核对