Dayward AI
Week 3 · D18About 6 hours

History Fidelity and Summarization, Multimodal Placeholders, Checkpointer Persistence

Keep a long conversation's history from getting distorted inside a multi-agent graph, add a summarization node that compresses early content, and persist execution state with a checkpointer.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Implement a summarization node that generates a summary and keeps key information once history grows too long
  2. Wire in LangGraph's checkpointer to persist the graph's execution state
  3. Resume an unfinished execution from a checkpoint and run it to completion

Yesterday's graph splits tasks, runs in parallel, and bounces work back for a redo, at nine model calls a run. It leaves two problems unsolved: two rounds of the review loop make the history unreadable, and all of it lives in memory, so a crashed process wastes nine model calls' worth of money. Today deals with both.

Plain-Language Walkthrough

A site log may be summarized; the excavation depth may not be dropped

A construction site keeps a daily log. Three months in, the log is a stack, so the project manager writes a summary at the end of each phase compressing that stack into two pages. But a few things can only be copied verbatim, never rewritten: how deep the excavation went, which batch the concrete came from, which day the client approved a change in writing, and who signed. The next trade builds to those numbers and the inspector assigns accountability by those signatures — a summary may read beautifully, and one wrong number there is an incident.

That is history fidelity: compression is necessary, and a small subset of what gets compressed must still be there afterwards, traceable to who said it at which step.

Draw the boundary between the three layers of memory first — each has had its own day and they blur together easily:

  • D6 is short-term context compression. Its object is the request about to be sent to the model: over the limit, swap a few early rounds for a summary before sending, and the compressed result is not persisted, being recomputed next round.
  • D12 is long-term memory. Its object is cross-session user facts and preferences, which do not enter the message array but live in a vector store, retrieved and injected on demand.
  • Today is graph execution state. Its object is this graph's entire state at this instant — messages, workspace, review rounds, attachments, degradation flag, all of it. It must be compressible and also storable, retrievable, and restartable from some earlier moment.

The difference is not magnitude, it is what happens when something is lost. Lose what D6 compressed and this round answers slightly worse, with the original still in the transcript; today's compression targets the graph state itself, which the checkpoint persists verbatim, so every later resume on this thread reads the compressed version. Loss here is permanent.

Multi-agent adds one more difficulty, and it is where the word fidelity comes from. D17's Critic judges an output by whether "this conclusion was written by the executor or requested by the user." A flattened narrative summary — "the user asked about orders and refunds, and the assistant proposed a plan" — reads smoothly and lets the Critic judge nothing: it cannot tell which sentence is the output under review and which is the acceptance criterion. In a single agent a summary loses detail; in a multi-agent system a summary loses the criteria.

So today's summary carries a requirement harder than D6's: every compressed message leaves two coordinates in the summary, its index and its speaker. The implementation is one line, as you will see. The genuinely hard question is the next one: once this state is compressed, where do you intend to store it?

When a phase summary is written, and by whom

Fix the triggers first, and compress when either line is reached: more than 20 messages, or an estimate over 8,000 tokens. Tokens follow D6's conservative estimate — one character per token, deliberately high, because underestimating means the threshold never fires.

Why two lines? Count alone means one five-thousand-word message can burst the window while the count is still 1; tokens alone mean a run of very short tool results can pile the count to hundreds, where serialization alone starts slowing every checkpoint write. Each line guards against a different shape of history.

How much original text is kept? This course takes the last 6 untouched, not one word rewritten. There is a trap D6 covered that deserves one line here: the cut must align to a round's start — cut between a tool call and its matching result and the next request contains a dangling call, which most vendors answer with a 400. So the cut only moves backwards, and the kept originals may exceed 6 and are never fewer.

Who generates the summary? One model call. And here is a different choice from D6: D6 pushed the summary back into the message array as a user message, and today does not — the summary goes into its own summary field. Because graph state gets persisted, and keeping originals and derived data separate is what makes it possible to regenerate with a different strategy later; mixed together, you can never again tell which entry genuinely happened and which was written after the fact.

One more hurdle you do not know until you hit it: an accumulating channel cannot get shorter. D15 gave messages a concatenating merge rule, so a summarization node returning a short array of 6 has it appended after the original 23 and the history grows to 29. To let it shrink, the reducer must understand a "replace wholesale" write instruction — that is not a hack, since LangChain's own message channel accepts a special message to express deletion. A channel's stored type and its written type may differ, and that is the only way an accumulating channel can subtract.

summarize.js
// The channel's write instruction: an array appends, { replace } replaces wholesale.
// The latter is the only way an accumulating channel gets shorter
const messagesReducer = (old, next) => (Array.isArray(next) ? old.concat(next) : next.replace)
 
const MAX_MESSAGES = 20
const MAX_TOKENS = 8000
const KEEP_RECENT = 6
 
export function needsSummary(messages) {
  if (messages.length > MAX_MESSAGES) return `count ${messages.length} exceeds ${MAX_MESSAGES}`
  const tokens = messages.reduce((n, m) => n + m.content.length, 0) // 1 char is about 1 token
  return tokens > MAX_TOKENS ? `estimated ${tokens} tokens exceeds ${MAX_TOKENS}` : null
}
 
// The delta holds only messages and summary. attachments never appears - the summary
// must not see it
export async function summarizeNode(state) {
  if (!needsSummary(state.messages)) return {}
  let cut = state.messages.length - KEEP_RECENT
  while (cut > 0 && state.messages[cut].role === 'tool') cut -= 1 // align the cut to a round's start
  if (cut <= 0) return {}
  // Every line carries an index and a speaker: lose those two and the Critic cannot tell
  // whose conclusion it is
  const transcript = state.messages
    .slice(0, cut)
    .map((m, i) => `#${i + 1} ${m.role}: ${m.content}`)
    .join('\n')
  const digest = await callModel('summarize', transcript)
  return { messages: { replace: state.messages.slice(cut) }, summary: digest }
}

The lift has not arrived, and the shaft is left open

Lift equipment often arrives months after the structural work, and no crew says "cut a hole in the wall once it turns up" — the shaft is drawn into the plans from the start, empty for most of a year. Because once the main structure is poured, changing it costs a fortune.

The attachments field is that shaft. Today wires up no image model and does no image understanding, only two things: keep that channel in the state, and guarantee it passes through both summarization and checkpointing untouched.

Why leave it now rather than add it later? Because once the graph state's shape has been persisted by checkpoints, changing a field is not a code change but a data migration: hundreds of thousands of checkpoints written in the old shape sit in the database, and adding a required field makes all of them read back missing it. Leaving an empty array now costs nothing.

The field's shape was fixed on D15: id, kind, ref, and an optional caption. Two decisions deserve full explanation.

One, store a reference rather than the content. ref is an address into object storage, not base64. Numbers say it clearest: in this course's lab one request writes 6 checkpoints, and storing references only makes this thread 6.8 KB in Postgres and 19 milliseconds end to end; base64-encode a 384 KB image into ref and the same thread becomes 4.2 MB and 125 milliseconds. One extra byte in the state is written six times per execution. That is checkpointing's most counterintuitive cost structure: you did not store one copy, you stored one per step.

Two, that description field is deliberately called caption rather than summary. Because AgentState.summary already means "the summary of early messages." Two summary fields meaning different things in one state type is the kind of error a review misses and production finds.

The summarization node must skip attachments entirely, and the reason is now clear: attachments store references rather than content, so summarizing a reference is filling in the shaft. A summary turns "a photo of the damage at blob://tickets/2026/att-1.png" into "the user uploaded a photo" — the photo is still in object storage and nobody knows its address any more. A lost original can be recovered elsewhere; a lost reference orphans that object for good.

Signing off at each phase: where checkpoints go and what they contain

At the end of each phase the client, the inspector, and the contractor sign off together. Signing seals that phase; a problem in the next phase means reworking from the last signature rather than re-digging the foundation.

A checkpointer is that signing regime: after every superstep the graph stores a copy of the entire current state. What is stored is a checkpoint, with three indispensable parts:

  1. A state snapshot: every channel's value at this instant.
  2. A parent pointer: which checkpoint came before. It strings a thread into a chain and makes forking possible.
  3. The steps not yet run: nodes queued but unexecuted at the moment of interruption, with their arguments. This one is the most overlooked, and the next section is entirely about it.

Where does it go? An in-memory version explains the principle and runs local labs, and production must persist. Today we write one against Postgres, with one table of five fields:

SQLSQL
create table if not exists graph_checkpoints (
  thread_id     text        not null,   -- which conversation thread
  checkpoint_id text        not null,   -- which instant on that thread, monotonic
  parent_id     text,                   -- the previous checkpoint, forming a chain
  state         jsonb       not null,   -- snapshot plus metadata plus the pending steps
  created_at    timestamptz not null default now(),
  primary key (thread_id, checkpoint_id)
);

Stored as what? jsonb. Chosen because it is queryable, indexable, and legible in psql during an incident. Three costs:

One, the larger the state the slower the write, which is the previous section's arithmetic. So what belongs in state is pointers and conclusions rather than raw material: attachments as references, retrieval results as document ids and excerpts, long tool output replaced by a conclusion once consumed.

Two, jsonb has a ceiling, and it starts hurting earlier than you think. The hard limit per field is 1 GB, which sounds remote; but a row over roughly 2 KB moves out to a TOAST table, adding an IO on every read and write. So the real engineering line is not letting one checkpoint become hundreds of KB — three rounds of the review loop with no compression gets there easily.

Three, the most overlooked: version compatibility on restore. A checkpoint stores the state shape as the code of that time saw it. Rename a field once or add a required field once and the checkpoints in the database no longer match the new code — and they do not disappear, since a user may click back in at any time. Worst of all, it usually does not throw. The lab manufactures a v1-shaped old checkpoint (the attachment's reference field was called url then, and subtasks had no toolCalls), and new code reading it prints:

TextText
without migration: the attachment reference reads as ["undefined"] and the budget computes NaN (nothing errors)
after migration: the attachment reference is ["blob://tickets/2025/att-old.png"] and the budget is 1

The string "undefined" gets spliced into user-facing copy; NaN is always false against 5, so D17's tool-budget ceiling is completely defeated on this thread. Three disciplines: migrate on the read side (bulk-rewriting the database is waste, since the vast majority of checkpoints are never opened again); migration only fills defaults and renames, never makes business judgments (if it can fail, you cannot restore); and discard old fields once read.

store.js
// A checkpoint is a snapshot plus pending steps plus a parent pointer. The parent pointer
// strings a thread into a chain, and a fork is a branch off that chain
export class CheckpointStore {
  #rows = new Map() // keyed by `${threadId}/${id}`; in production this layer is graph_checkpoints
 
  put(threadId, cp) {
    this.#rows.set(`${threadId}/${cp.id}`, cp)
  }
 
  get(threadId, id) {
    return this.#rows.get(`${threadId}/${id}`)
  }
 
  // checkpoint_id is monotonic, so "latest" is the largest key on this thread
  latest(threadId) {
    const line = [...this.#rows.keys()].filter((k) => k.startsWith(`${threadId}/`)).sort()
    return line.length > 0 ? this.#rows.get(line[line.length - 1]) : undefined
  }
 
  // Walk back along parent pointers. Which steps replay and where a fork branches
  // both rest on this chain
  ancestors(threadId, id) {
    const chain = []
    for (let cp = this.get(threadId, id); cp; cp = cp.parentId && this.get(threadId, cp.parentId)) {
      chain.unshift(cp)
      if (!cp.parentId) break
    }
    return chain
  }
}

Java and Swift have no LangGraph, as D15 said: those two are not calling a library but hand-writing the same mechanism idiomatically. Written out, the core is a table indexed by thread and instant plus a parent-pointer chain — and what the framework is genuinely worth at this layer is deciding for you when that signature should be taken.

On a problem, restart from the last signature

With signatures, rework has a starting point. A checkpoint's two uses look alike and serve entirely different purposes:

  • Resume: this thread did not finish last time, so carry on. The coordinate needs only thread_id.
  • Fork: go back to some earlier moment and take a different route. The coordinate needs thread_id plus checkpoint_id.

Resume carries one rule to memorize: do not supply input. The state is already in the checkpoint, and calling again with the original sentence does not error — the framework treats this input as a fresh state update stacked on the interruption point, and the history becomes two copies. In the lab, a correct resume takes messages from 1 to 2 (only the reply added) and an incorrect one takes it to 3.

Fork's symmetric error is forgetting checkpoint_id: with only thread_id you get this thread's latest state, so "redo from step 2" becomes "carry on after the last step." Equally silent, and only comparing subtask lists reveals it. Together that is this section's rule: resume means carry on, fork means go back, and both are silent when written wrong.

resume.js
// Resume: no input. The state is in the checkpoint, and next holds the steps not yet run
// along with their arguments, so no node already executed runs again. Feeding the history
// again is not resuming, it appends another input at the interruption point
export const resume = (graph, threadId) =>
  graph.invoke(null, { configurable: { thread_id: threadId } })
 
// Fork: the same thread, with the coordinate pointing back to an earlier moment.
// Without checkpoint_id it degrades to the latest one - "redo from step 2" becomes
// "carry on after step 6"
export const forkFrom = (graph, threadId, checkpointId, input) =>
  graph.invoke(input, { configurable: { thread_id: threadId, checkpoint_id: checkpointId } })

Now back to yesterday's open question: D17 fans out dynamically with Send, so were those undispatched subtasks stored on interruption? Today's measurement answers: yes, with their full arguments. Interrupted before the Executor, the checkpoint's channel list holds, besides seven business fields, an internal channel __pregel_tasks containing three records like this:

JSONJSON
[
  { "node": "executor", "args": { "task": { "id": "t-1-order", "goal": "look up the order status and describe the current stage (order SO20260901)", "toolCalls": 0, "status": "pending" } } },
  { "node": "executor", "args": { "task": { "id": "t-2-shipping", "goal": "look up shipping events and give an estimated delivery time (order SO20260901)", "toolCalls": 0, "status": "pending" } } }
]

Three conclusions. One, a resume does not rerun the Planner — measured, the resume phase's model-call count equals exactly the number of pending subtasks, because the to-do list is in the checkpoint. Two, Send's arguments are stored as ordinary JSON, so only serializable things belong there; put in a class instance, a function, or a database connection and it restores as an empty shell. Three, it lives in an internal channel, not in your business fields. So a bespoke checkpointer that stores the snapshot and omits the pending steps loses every pending fan-out on resume — the graph looks finished and not one item was dispatched, with no error at all.

Source Reading

Hands-On Lab

🧪 D18 lab: a long conversation with a checkpointer plus a summarization node

Code location: labs/agent-30days/day-18-checkpointer-summary

Acceptance criteria:

  1. All five self-checks under MOCK=1 SELFTEST=1 pnpm start pass with exit code 0 (starter/ as-is is 1 pass and 4 failures, each naming its exercise).
  2. Check 1: 23 messages of history go in and what comes out is the last 6 verbatim plus one summary carrying indices and speakers; another thread with only 17 messages but an estimate over 8,000 tokens triggers the same; and both threads' attachments are complete with no field changed.
  3. Check 2: the graph interrupts before the Executor, and after rebuilding the store handle and the graph object the state read back matches the moment of interruption exactly; after resuming, the history gains only that one reply, and the resume phase's model-call count equals exactly the number of pending subtasks.
  4. Check 3: the interruption checkpoint's __pregel_tasks channel holds 3 fully serialized Sends, each with its node name and subtask arguments.
  5. Checks 4 and 5: forking from the same checkpoint with a different sentence splits subtasks differing from the main thread's and excluding the main thread's item; an old-shaped checkpoint silently reads "undefined" and NaN without migration and reads correctly after it.

Today has an infrastructure dependency, and the lab root ships a docker-compose.yml mapping Postgres to host port 5518. Without DATABASE_URL it uses the in-memory version, running the same graph and the same self-checks, and both routes must produce identical results. Note that MOCK=1 only makes model calls offline; whether checkpoints go to memory or Postgres is decided by DATABASE_URL. src/shared/state.ts comes from D15 unchanged. Run it once first; those four failures are your to-do list.

  1. Run MOCK=1 SELFTEST=1 pnpm start as-is and study check 1's "23 compressed into 23" and check 2's "history goes from 1 to 3" — those two are today's symptoms.
  2. Exercise 1, implement the two trigger lines and the summary cut, taking check 1 from not shrinking to 6 originals plus one summary with indices and speakers.
  3. Exercise 2, change the resume to carry no input, so check 2's history goes from a whole extra copy to one extra reply and the resume phase's model calls drop to the subtask count.
  4. Exercise 3, add checkpoint_id to the fork, so check 4's forked thread no longer carries the main thread's item.
  5. Exercise 4, implement field migration for old checkpoints, taking check 5 from "undefined" and NaN to correct values; then bring up compose once and use psql to see what graph_checkpoints actually holds.

Interview Questions

Today's four questions are in the bank below, weighted toward memory layering and checkpoints and replay; the first, on how to layer memory, is almost guaranteed to come up, and the third, on what to watch out for in replay, discriminates most. Expand a question and read the analysis before the key points; practicing the derivation beats memorizing the answer. Each is tagged for the China-domestic or overseas market so you can prioritize by where you are applying.

Checklist and Tomorrow

  • Implement a summarization node that generates a summary and keeps key information once history grows too long
  • Wire in LangGraph's checkpointer to persist the graph's execution state
  • Resume an unfinished execution from a checkpoint and run it to completion
  • Say what short-term context, long-term memory, and graph execution state each govern, and the consequence of losing each
  • Name checkpointing's three real costs, and explain why attachments store references rather than content
  • Say how resume and fork differ in their coordinates, and why both are silent when written wrong
  • All 5 acceptance criteria of the lab pass (all five self-checks green)
  • Answer at least 3 of the 4 interview questions without looking at the key points

Tomorrow (D19) connects this multi-agent service into W2's mini-koda, the first time the two milestone projects join up. Why after today? Because the first thing anybody asks about an agent called across services is "is my case still there after you crash," and only today made it able to answer. Tomorrow's subject is a different question: on what basis do two services trust each other? The answer is not one shared secret but having the caller arrive carrying the user's own passport.

Interview questions

  • How should agent memory be layered? What belongs in short-term context, in summaries, and in long-term memory — and what happens when each is lost?记忆应该怎么分层?短期上下文、摘要、长期记忆分别放什么、丢了会怎么样?
    Common in ChinaCommon overseasBasic#memory#context-management#multi-agent

    How to reason about it · think before answering

    1. The discriminator here is not listing three layers, it is saying what breaks when each one is lost. An answer that only names the layers tells the interviewer you have never operated one.
    2. Offer a reusable split first: sort any memory scheme by who reads it, how long it lives, and whether it can be rebuilt after loss. Those three questions cut through every design.
    3. Short-term context is the message array sent to the model this turn. It dies with the request and is billed in full every turn. Losing it only costs coherence for that turn, because the raw transcript still lives in your own store and can be replayed.
    4. A summary is derived from short-term context, produced to shrink early turns before the window fills. It can be regenerated after loss — but only if the raw transcript was stored separately. That is the practical reason a summary must never overwrite the original.
    5. Long-term memory holds cross-session user facts and preferences. It never enters the message array; it lives in a retrieval layer and a few hits get injected on demand. Losing it means the system forgot the user — single requests still work, but the product gets noticeably worse.
    6. Multi-agent adds a fourth layer people usually miss: graph execution state — messages, shared workspace, review rounds, degraded flags. It is the only copy that gets checkpointed and replayed on resume, and losing it is the most expensive failure: a run that already burned nine model calls starts over while the user watches a spinner.
    7. Expect the follow-up: should the summary live inside the message array or in its own field? Say its own field — keeping raw and derived data apart is what lets you regenerate with a different strategy later; merged together you can no longer tell what actually happened from what was written after the fact.

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

    1. 这题的区分度不在能不能列出三层,而在能不能说出**每一层丢了会怎样**。只报名词的答案,面试官听不出你有没有真的运维过。
    2. 先给一条可复用的拆法:按「谁在读它、活多久、丢了能不能补」三个问题去分,任何一个记忆方案都能被这三问切开。
    3. 短期上下文是这一次请求要发给模型的那个消息数组,随请求结束作废,全量进 token 账单;它丢了只影响这一轮的连贯性,原文还在你自己的会话记录里,可以重放。
    4. 摘要是短期上下文的派生数据,用来在窗口顶到之前把早期内容压短;它丢了可以重新生成——**前提是原文另存了一份**。所以摘要绝不能覆盖原文,这是「压缩不可逆」那条纪律的实际落点。
    5. 长期记忆是跨会话的用户事实与偏好,不进消息数组,存在外部检索层里按需捞几条注入;它丢了的表现是「这个用户被系统忘光了」,不影响单次可用,但产品价值直接掉一层。
    6. 多 Agent 还要补第四层,也是最容易被忽略的一层:**图的执行状态**。它包含消息、共享工作区、评审轮次、降级标记,是唯一一份会被检查点持久化并在恢复时重放的数据。它丢了的后果最重——一次已经花掉九次模型调用的执行必须从头再来,而且用户界面还停在转圈。
    7. 可以预期的追问:摘要该放在消息数组里还是单独一个字段?答单独字段,理由是原文与派生数据要分开存,才可能换一种策略重新生成;混在一起之后你分不清哪条是真发生过的、哪条是事后编的。

    Key points

    • Layer by who reads it, how long it lives, and whether it can be rebuilt — that beats reciting names
    • Short-term context: this turn's message array, discarded after the request, billed in full, replayable from your own transcript
    • Summary: derived from short-term context and regenerable, but only if the raw transcript is stored separately — so it must never overwrite the original
    • Long-term memory: cross-session user facts in a retrieval layer, injected on demand; losing it means the system forgot the user
    • Multi-agent adds graph execution state — messages, workspace, review rounds, degraded flags — checkpointed and replayed on resume, and the most expensive to lose
    • Keep the summary in its own field rather than back in the message array, so raw and derived data stay separable

    答题要点

    • 按「谁在读、活多久、丢了能不能补」三问分层,比背名词有用
    • 短期上下文:本轮请求的消息数组,随请求作废,全量计费,丢了可从原始记录重放
    • 摘要:短期上下文的派生数据,可重新生成,前提是原文另存——所以摘要不能覆盖原文
    • 长期记忆:跨会话的用户事实,存在检索层按需注入,丢了是「系统忘了这个人」
    • 多 Agent 多一层图执行状态:消息 + 工作区 + 评审轮次 + 降级标记,会被检查点持久化并在恢复时重放,丢了最贵
    • 摘要放独立字段而不是塞回消息数组,原文与派生数据分开存才可能换策略重生成
  • When summarizing a long conversation, how do you keep the critical information from being lost — and what is different about this in a multi-agent system?长对话做摘要时,怎么保证关键信息不丢?在多 Agent 场景下这件事有什么特别的?
    Common in ChinaCommon overseasIntermediate#context-compression#multi-agent#reliability

    How to reason about it · think before answering

    1. The hinge is the second half. Answering only keep user constraints and the last few turns is the standard single-agent answer — passable, not memorable. Asking what is different in multi-agent is asking whether you have actually hit this in a collaboration graph.
    2. Get the single-agent half solid first: trigger on thresholds, never a timer. This course uses more than 20 messages or an estimated 8000 tokens, counting one character as one token — deliberately high, because underestimating means the threshold never fires. Keep the last 6 messages verbatim. Align the cut to a turn boundary: cutting between a tool call and its result produces a dangling message and most vendors return 400.
    3. Then name the real difference: in a single agent a summary loses detail; in a multi-agent graph a summary loses the criteria. A critic decides whether output passes by telling apart what is being reviewed from what the requirement was. A smooth narrative summary that flattens speakers reads fine and is useless to the critic.
    4. So multi-agent summarization has one extra hard requirement: every compressed message must leave behind two coordinates — its index and its speaker. The implementation is one line: build the transcript with numbered, role-prefixed entries before handing it to the model.
    5. Add the boundary that shows you have shipped this: summarize natural-language history only, never structured fields. Compressing the shared workspace into a sentence kills every lookup by task id and every comparison against an acceptance requirement, and structured data does not come back. Attachments are even more off-limits — they hold a reference, not content, so summarizing one orphans the underlying object.
    6. Expect the follow-up: which model writes the summary, and what if it fails? A cheaper small model is fine since the job is condensation, not reasoning. On failure the correct behavior is to skip this round of compression, keep running, and alert — not to fail the whole execution. Setting the threshold at seventy or eighty percent exists precisely to leave that rescue room.

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

    1. 题眼在后半句。只答「保留用户约束、保留最近几轮」是单 Agent 的标准答案,能过但不出彩;面试官问「多 Agent 有什么特别的」,是在看你有没有真的在协作图里踩过这个坑。
    2. 先把单 Agent 那半答扎实:触发用阈值不用定时器,本课口径是消息超过 20 条或估算超过 8000 token(一个字符算一个 token,故意高估,低估会让阈值永远触发不了);保留最近 6 条原文不动;切口必须对齐到一轮的开头,切在工具调用与工具结果之间会让下一次请求出现悬空消息,多数厂商直接返回 400。
    3. 然后给出多 Agent 那半的关键差别:单 Agent 里摘要丢的是**细节**,多 Agent 里摘要丢的是**判据**。评审者判一份产出合不合格,靠的是分清「这句是待验收的产出、那句是验收要求」;一段把发言人抹平的流水摘要读起来通顺,但评审拿它做不了任何判断。
    4. 所以多 Agent 的摘要有一条额外硬要求:每条被压掉的消息,在摘要里都要留下「第几条 + 谁说的」这两个坐标。实现只有一行——把转录写成带序号和角色前缀的形式再交给模型。
    5. 再补一条边界,这条最能显出你写过:**摘要只对自然语言历史动手,不碰任何结构化字段**。把共享工作区压成一句话,「按 id 找到某条子任务、比对验收要求」就整个失效了,结构化数据压成自然语言就再也回不去。附件字段更是碰不得——它存的是引用不是内容,摘要掉等于把那个对象变成孤儿。
    6. 可以预期的追问:摘要用哪个模型、失败了怎么办?答可以用更便宜的小模型(它只做归纳不做推理),失败时的正确行为是**跳过这一轮压缩继续跑**并告警,而不是让整次执行失败——阈值定在七八成就是为了留出这次抢救余量。

    Key points

    • Trigger on thresholds, not timers: more than 20 messages or an estimated 8000 tokens, counting one character as one token to stay conservative
    • Keep the last 6 messages verbatim and align the cut to a turn boundary, or you ship a dangling tool call and the request 400s
    • The multi-agent difference: a summary loses criteria, not just detail — the critic needs to know who said what and at which step
    • So every compressed message keeps its index and speaker in the summary; the implementation is a numbered, role-prefixed transcript
    • Summarize natural-language history only — never the shared workspace or other structured fields, and never the attachment references
    • A cheaper small model is fine for summarizing; if the call fails, skip compression for this round and alert rather than failing the run

    答题要点

    • 触发用阈值不用定时器:超过 20 条或估算超过 8000 token,token 按一字符一 token 保守高估
    • 保留最近 6 条原文不动,切口必须对齐到一轮开头,否则会出现有调用没结果的悬空消息、请求直接 400
    • 多 Agent 的差别:摘要丢的不是细节而是判据,评审者靠「谁在第几步说的」区分产出与验收要求
    • 所以每条被压掉的消息都要在摘要里留下条号与发言人,实现就是把转录写成带序号和角色的形式
    • 只压自然语言历史,不碰共享工作区这类结构化字段,更不能碰存引用的附件字段
    • 摘要可用更便宜的小模型;摘要调用失败时跳过这一轮压缩并告警,不要让整次执行失败
  • What do you need to watch out for when replaying execution from a checkpoint? Give failure modes you would actually hit.从 checkpoint 恢复执行(replay)需要注意什么?说几个真实会踩的坑。
    Common in ChinaCommon overseasDeep dive#checkpointing#replay#reliability

    How to reason about it · think before answering

    1. The easy failure is answering just load it and keep going. The discriminator is recognizing that almost every replay bug is silent — no exception, clean logs, plausible output, and you only notice when you diff the data. Saying that up front wins half the question.
    2. Give a chain first: a checkpoint stores the state shape as the code of that moment understood it, and replay pushes it back into today's code. So every failure comes from a mismatch across those two ends — the shape of the data, the entry point of execution, and things that should never have been replayed at all.
    3. Trap one: feeding the input again on resume. Resume takes no input; the state is already in the checkpoint. Passing the original message once more makes the framework treat it as a fresh update stacked on the interrupt point, and the history quietly doubles. Nothing throws.
    4. Trap two: forking without a checkpoint id. With only the thread id you get that thread's latest state, so start over from step 2 silently becomes append after the last step. Again nothing throws; you only see it by diffing the task list.
    5. Trap three: version drift. Rename a field or add a required one and every old checkpoint stops matching the new code. A missing field reads as undefined, which renders as the literal string undefined in user-facing text and as NaN in arithmetic — a tool-budget ceiling compared against NaN is always false, so the budget silently stops existing on resumed threads. Migrate on read, and keep the migration to defaults and renames only: it must never fail.
    6. Trap four: replayable data that should not be replayed. A one-off human override written into graph state gets checkpointed and re-applied on every resume. The test: does this describe how this run executes, or what this conversation is? The former belongs in runtime config, only the latter in state.
    7. Expect the follow-up: are pending parallel tasks preserved? Yes — a checkpoint holds not just the state snapshot but the steps not yet run, arguments included, so the planner does not re-run. But they live in a framework-internal channel, so a hand-rolled store that persists state and forgets that half will resume into a graph that looks finished while no work was ever dispatched.

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

    1. 这题最容易答成「读出来接着跑就行」。区分度在于你能不能说出**这些坑几乎全是静默的**——不抛异常、日志干净、结果看起来也对,只有对比数据时才发现不对。能说出这一点,答案就已经赢了一半。
    2. 先给一条推导链:检查点里存的是「当时那个版本的代码眼里的状态形状」,恢复就是把它塞回今天这个版本的代码里。所以所有坑都来自**两端不一致**:数据的形状、执行的入口、和那些不该被重放的东西。
    3. 坑一,恢复时又把输入喂了一遍。恢复的入口是不带输入地调用,状态已经在检查点里;带着原来那句话再调一次,框架会把它当成一次新的状态更新叠在中断点上,历史变成两份。它不报错。
    4. 坑二,分叉忘了带检查点 id。只给会话 id 拿到的是这条线最新的状态,于是「从第 2 步重来」变成了「在最后一步后面接着写」。同样不报错,只有对比子任务列表才看得出来。
    5. 坑三,版本兼容。改一个字段名、加一个必填字段,库里的老检查点就和新代码对不上;而缺字段读出来是 undefined,拼进文案就是字符串「undefined」,参与算术就是 NaN——比如工具预算的上限判断,一旦变成 NaN 比较,恒为假,预算上限在恢复出来的那条线上彻底失效。正确做法是在读的那一侧迁移,迁移函数只补默认值和改名、不做业务判断,绝不能失败。
    6. 坑四,不该被重放的东西进了状态。一次性的人工干预(比如人工改派)如果写进图状态,就会被检查点持久化并在每次恢复时重放一遍。判断口径:这条信息说的是「这一次执行怎么跑」还是「这个会话是什么」,前者进运行时配置,后者才进状态。
    7. 可以预期的追问:待执行的并行子任务存不存?答存——检查点里除了状态快照还有一份「还没跑的那几步,连参数一起」,所以恢复不用重跑规划节点;但它存在框架的内部通道里,自研存储层只实现「存状态」而漏掉这一半,恢复出来的图会看起来跑完了、其实一件活都没派出去。

    Key points

    • Lead with the pattern: replay bugs are almost all silent — no exception, clean logs, plausible output
    • Resume takes no input; passing one appends another update at the interrupt point and doubles the history
    • Forking requires the checkpoint id — thread id alone lands on the latest state, turning start over from step 2 into append after the end
    • Version drift: missing fields read as undefined or NaN, so comparisons like a tool-budget ceiling become permanently false. Migrate on read, restricted to defaults and renames, and never let it fail
    • Keep one-off human overrides out of graph state or they get persisted and re-applied on every resume — how this run executes belongs in config, what this conversation is belongs in state
    • Pending parallel tasks are stored with their arguments, so the planner does not re-run; a hand-rolled store that skips that half resumes into a graph that dispatches nothing

    答题要点

    • 先点破共性:replay 的坑几乎全是静默的,不报错、日志干净、结果看着也对
    • 恢复不要带输入,带了就是在中断点上又追加一次,历史变成两份
    • 分叉必须带检查点 id,只给会话 id 会落在最新状态上,「从第 2 步重来」变成「接着往后写」
    • 版本兼容:缺字段读出来是 undefined 或 NaN,会让预算上限之类的比较恒为假;在读的那一侧迁移,迁移只补默认值和改名且不能失败
    • 一次性的人工干预不要进图状态,否则会被持久化并在每次恢复时重放;「这次怎么跑」进配置,「这个会话是什么」才进状态
    • 待执行的并行子任务连参数一起存在检查点里,所以恢复不重跑规划;自研存储层漏掉这一半,恢复出来的图会一件活都不派
  • What problem does a checkpointer solve in a multi-agent system, and what does it cost?checkpointer 在多 Agent 系统里解决了什么问题?它的代价是什么?
    Common in ChinaCommon overseasIntermediate#checkpointing#cost#operations

    How to reason about it · think before answering

    1. The second half is the real question. Answering it enables recovery and fault tolerance is a feature blurb any doc carries. The interviewer wants to know whether you have done the arithmetic and where it hurts.
    2. Make the value concrete in money and time: one multi-agent run with a review loop costs nine model calls. If the process is restarted for a deploy at call seven, without checkpoints all nine are wasted and the user is still watching a spinner. With them the run continues from the last signed-off point and no completed node re-runs. What you bought is a smaller unit of failure — a node instead of a whole run.
    3. Mention the three things it unlocks that retries alone cannot: human approval gates (pause before a node and the state simply waits), time-travel debugging (go back to just before the bad step and inspect state), and forking for comparison (run two variants from one checkpoint) — which is also the infrastructure evaluation is built on.
    4. Then the costs, all three. First, bigger state means slower writes, and it is written at every step: one request produces six checkpoints, so a byte added to state is six bytes written. Hence attachments hold references, not content, and retrieval results hold document ids, not full text.
    5. Second, the store has limits. With jsonb the hard cap is far away, but a row past roughly two kilobytes gets pushed to out-of-line storage and costs an extra IO on every read and write. The real engineering line is do not let a single checkpoint reach hundreds of kilobytes, not the theoretical cap.
    6. Third, the one people forget: version compatibility. Checkpoints are long-lived data, so every change to the state shape incurs migration debt, and missing fields usually do not throw — they silently yield undefined or NaN. This is why state fields should be reserved early: once a shape is persisted, changing a field is a data migration, not a code edit.
    7. Expect the follow-up: do checkpoints need cleanup? Yes — retention and archival per thread, or the table grows linearly with active users. Also treat it as sensitive data: graph state contains full conversations, so it must be included whenever you delete a user's data.

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

    1. 这题的下半句才是考点。只答「能恢复、能容错」是功能介绍,任何文档都写着;面试官想听的是你有没有算过这笔账,以及知不知道它会在哪里疼。
    2. 先把价值说具体,用钱和时间说:一次带评审回路的多 Agent 执行要调九次模型,跑到第七次进程被换版本重启,没有检查点就是九次全废、用户界面还停在转圈。有检查点则从上一个签字点接着跑,已经跑完的节点一次都不重跑——它买的是「失败的粒度从一整次执行降到一个节点」。
    3. 顺带说清它解锁的另外三件事,这三样单靠重试做不到:**人工审批闸口**(在某个节点前停下等人点确认,状态就停在那儿)、**时间旅行调试**(回到出问题那一步之前看状态长什么样)、**分叉对比**(从同一个检查点跑两种走法,比较结果,这也是评估的基础设施)。
    4. 然后是代价,三笔要说全。第一笔,**状态越大写得越慢**,而且是每一步都写一份——一次请求写六个检查点,状态里多一个字节就要多写六遍。所以附件存引用不存内容,检索结果存文档 id 不存全文。
    5. 第二笔,**存储本身有上限**。用 jsonb 存的话,硬上限很远,但单行超过大约两 KB 就会被挪到外存、每次读写多一次 IO,所以真正的工程线是「别让单个检查点变成几百 KB」,而不是那个理论上限。
    6. 第三笔也是最容易被忽略的:**版本兼容**。检查点是长期存活的数据,你每改一次状态形状就欠下一笔迁移债,而缺字段读出来通常不报错,只是静默给出 undefined 或 NaN。这一条决定了状态字段要尽早占好位子——图状态的形状一旦被持久化,改字段就不是改代码,是数据迁移。
    7. 可以预期的追问:那检查点要不要清理?答要,按会话线设保留期与归档策略,否则这张表会随日活线性膨胀;另外要留意它是敏感数据——图状态里有完整对话,删除用户数据时这张表必须一起处理。

    Key points

    • Core value: it shrinks the unit of failure from a whole run to a single node, so a nine-call run is not wasted by one restart
    • It also unlocks three things retries cannot: human approval gates, time-travel debugging, and forking from one checkpoint to compare variants — the substrate evaluation is built on
    • Cost one: bigger state writes slower, and it is written at every step — hence references for attachments and document ids for retrieval results
    • Cost two: storage limits — a jsonb row past roughly two kilobytes goes out-of-line and costs an extra IO, so the practical line is keeping a checkpoint well under hundreds of kilobytes
    • Cost three: version compatibility — every change to the state shape is migration debt, and missing fields silently yield undefined or NaN, which is why fields should be reserved early
    • Operationally you need retention and archival, and you must treat it as sensitive data: graph state holds full conversations and must be purged with the user's data

    答题要点

    • 核心价值:把失败的粒度从「一整次执行」降到「一个节点」,九次模型调用的执行不会因为一次重启全废
    • 还解锁三件重试做不到的事:人工审批闸口、时间旅行调试、从同一个检查点分叉对比(也是评估的基础设施)
    • 代价一,状态越大写得越慢,而且每一步都写一份——所以附件存引用、检索结果存文档 id
    • 代价二,存储有上限:jsonb 单行超过约两 KB 就外存、多一次 IO,工程线是别让单个检查点到几百 KB
    • 代价三,版本兼容:状态形状改一次就欠一笔迁移债,缺字段静默给出 undefined 或 NaN;所以字段要尽早占位
    • 运维上还要有保留期与归档,并把它当敏感数据处理——图状态里有完整对话,删用户数据时必须一起删

Comments