Project Instruction Files: Three-Tier Loading, Import Expansion, and the System Prompt's Merge Order
Make the agent know the house rules the moment it enters a repo: implement discovery and loading of user-level, project-level, and directory-level instruction files, support imports between files, define a deterministic merge order and conflict rule, and give the user one command that shows exactly how the final system prompt was assembled.
Today's Goals
- Implement discovery and loading of three tiers of instruction files, and explain each tier's scope and priority
- Implement import expansion with cycle detection and a depth cap
- Define an explainable merge order, and provide one command that shows the user where the final result came from
Yesterday solved "what to look at this once"; today solves "what it should know every time." When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
The team handbook: which pages to read on day one, and whose word counts
The new hire can work now and knows which file to open. But notice that every morning you repeat the same few sentences: we do not write semicolons; run tests with node --test, not npm test; run the tests before changing code to confirm they are red; use only node:assert/strict for assertions.
By the fifth repetition you realize these should not be said daily — they belong in a handbook pinned to their desk.
mca's handbook today is one system prompt hard-coded in the source. To make it "know the rules the moment it enters a repository," the handbook must live in files. And the moment it does, a question appears: who writes this handbook?
The answer is all three, and their scopes differ:
- I have preferences: answer in my language, do not restate my question. That holds in every repository.
- This repository has rules: code style, how tests run, which directories not to touch. Everyone entering it should follow them.
- This area of code has extra rules: after changing anything under
src/, immediately run the narrower test command.
Three handbooks, three scopes, widest to narrowest. So today's work has a shape: three tiers of instruction files, each discovered, merged in a deterministic order into one system prompt. The order is one sentence — more specific goes later, and on conflict the later one wins.
That rule sounds bland, and it is the source of all of today's engineering. Because "the later one wins" means the earlier one must actually disappear, not both remain for the model to choose between; it means the user needs a way to see which one won; and it means those files import one another, and imports bring cycles, depth and boundary problems.
Where the three tiers come from: home directory, repository root, and walking down to the current directory
Discovery looks simplest and concentrates the most traps. Each tier is found differently.
User level is the most direct: a fixed filename in the user's home directory. But in an exercise environment you must never actually read or write the real home directory — another tool's file of the same name may already be there, and writing into it is unacceptable. The lab makes it an environment variable MCA_HOME, defaulting under work/home; a production implementation swaps in os.homedir() with no change to the discovery logic.
Project level requires finding the repository root first, and only one method is right: walk upward from the current directory looking for a marker (the lab accepts .git or package.json), stopping at the first one found.
Two common mistakes each have a cost. One is "go up three levels" — the user's directory depth is not yours to decide. The other is more dangerous: walking up to the filesystem root. Run once inside /tmp and you read /AGENTS.md — a path anyone can write to, whose contents enter your system prompt. So if no marker is found, treat the start directory as the root: better to read one tier too few.
Directory level walks down from the repository root to the start directory, checking each level. Note the direction: you may search inside-out, but the result must be outermost first, because that is the merge order. Sort during discovery and merging never sorts again.
export async function discover(options: DiscoverOptions): Promise<Found[]> {
const found: Found[] = []
const seen = new Set<string>()
const push = async (layer: Layer, file: string): Promise<void> => {
if (!(await exists(file))) return
// Deduplicate by real path: one real file found through two paths counts once
const real = await fs.realpath(file).catch(() => file)
if (seen.has(real)) return
seen.add(real)
found.push({ layer, path: file, label: display(file) })
}
await push('user', path.join(options.home, INSTRUCTION_FILE))
const root = await findRepoRoot(options.start)
await push('project', path.join(root, INSTRUCTION_FILE))
// Walk down from the repository root to the start directory: outermost first, and that
// ordering is the priority
let dir = root
for (const part of path.relative(root, path.resolve(options.start)).split(path.sep)) {
if (!part) continue
dir = path.join(dir, part)
await push('directory', path.join(dir, INSTRUCTION_FILE))
}
return found
}async def discover(home: Path, start: Path) -> list[Found]:
"""Three tiers, widest to narrowest. Key on realpath so a symlink counts once."""
found: list[Found] = []
seen: set[Path] = set()
async def push(layer: str, file: Path) -> None:
if not file.exists():
return
real = file.resolve() # resolve already follows symlinks all the way
if real in seen:
return
seen.add(real)
found.append(Found(layer=layer, path=file, label=display(file)))
await push("user", home / INSTRUCTION_FILE)
root = find_repo_root(start)
await push("project", root / INSTRUCTION_FILE)
# Reassemble segment by segment after relative_to: outermost first
for part in start.resolve().relative_to(root).parts:
root = root / part
await push("directory", root / INSTRUCTION_FILE)
return foundThe seen set deserves its own sentence: deduplicate by real path. Symlinking the project instructions into a subdirectory is common, and without deduplication the same content appears twice in the system prompt. The harm is more than cost — seeing two equally authoritative copies makes the model hesitate between them, and if one was truncated by the budget it may conclude the two differ.
Import expansion: relative paths, cycles, depth caps
A long handbook needs splitting, so instruction files need an import syntax. The lab uses @import ./testing.md alone on a line, with the path relative to the file that references it.
Why not reuse yesterday's @: that one is a user pasting material into a conversation ad hoc, where one line of feedback suffices when it breaks; this one is an author splitting a long-lived document, where breaking means every request loses a rule, so it must be reported explicitly.
Imports must block four things, and two of them are commonly conflated — the distinction most worth remembering today:
// chain is "the current import chain", popped on the way out - used for cycles
// seen is "everything imported during this whole expansion", never popped - used for duplicates
if (depth + 1 > maxDepth) {
problems.push(`the import in ${short(from)} is already at level ${depth + 1}, over the cap of ${maxDepth}`)
return `(import not expanded: over the depth cap of ${maxDepth})`
}
if (chain.includes(absolute)) {
// Print the whole chain, or the user has nowhere to start
problems.push(`import cycle: ${chain.map(short).join(' -> ')} -> ${short(absolute)}, skipped`)
return `(cyclic import skipped: ${short(absolute)})`
}
if (seen.has(absolute)) {
problems.push(`${short(absolute)} was already imported once; skipping (a duplicate counts once)`)
return `(duplicate import skipped: ${short(absolute)})`
}
seen.add(absolute)# chain is a list (ordered, so the whole chain can be printed); seen is a set (membership only)
if depth + 1 > max_depth:
problems.append(f"the import in {short(src)} is at level {depth + 1}, over the cap {max_depth}")
return f"(import not expanded: over the depth cap of {max_depth})"
if target in chain:
trail = " -> ".join(short(p) for p in (*chain, target))
problems.append(f"import cycle: {trail}, skipped")
return f"(cyclic import skipped: {short(target)})"
if target in seen:
problems.append(f"{short(target)} was already imported once; skipping")
return f"(duplicate import skipped: {short(target)})"
seen.add(target)A cycle looks at the import chain; a duplicate looks at whether it was imported at all. Using one set for both is the easiest wrong version: a diamond import — A imports B and C, both of which import D — is reported as a cycle, while a real cycle gets a message that explains nothing. The lab's self-test has an item for each, precisely to force the distinction.
The other two in one sentence each: the depth cap is three here, and deeper means the author should refactor the files rather than you supporting it; the root boundary can only be judged by "is the resolved absolute path still inside the root," and a user-level file may import only from the home directory — a repository's instruction file must not pull in files from the user's home directory, which would make the project a springboard.
One last discipline shared by all four cases: a bad line must not void the whole instruction file. Replace the offending line with a parenthetical note in place and let the rest take effect. The reason is practical — instruction files are written by other people, you cannot guarantee every line is right, and "one misplaced comma makes the whole repository unusable" is the worst possible design.
Merge order: more specific goes later, and the later one wins
Now assemble the three tiers. The order is:
built-in base (role + tool discipline) -> user level -> project level -> directory level (outer to inner)A natural objection: project rules matter most, so put them first where the model sees them. That has two costs.
One is self-contradiction. The rule we announce to the model is "later is more specific, and on conflict the later wins." The base is the broadest section; putting it last sets the rule against the implementation.
The other is money. Every turn resends the whole message array, and the prompt's prefix has a chance of being cached by the gateway — that is what cached_tokens in day one's measured table is. Putting instructions that may change every time at the front means a new prefix every turn. Putting the least-changing section first is the only free optimization.
With the order fixed, how does "the later wins" get implemented? One key decision here: the overridden line must actually be deleted from the text.
Recording the conflict and leaving both lines in the text is the easiest and worst approach: the model sees both "test command: npm test" and "test command: node --test test/calc.test.js," you do not know which it picks, and your report to the user says "the later one wins." A report that disagrees with what was actually sent is worse than no report.
To delete, you must first recognize. The lab recognizes one shape of instruction: - key: value alone on a line. Prose stays, but only that shape takes part in conflict resolution. That is a deliberate trade-off — a conflict that can be resolved automatically must be one a machine can recognize. Two contradictory prose paragraphs (one saying "be concise," another "explain in detail") cannot be recognized, so the tool should not pretend to handle them; that kind of conflict is found only by a human reading /context.
Instructions competing with tool descriptions: what goes first in the system prompt
An unavoidable question: with so many tool descriptions, where in the system prompt do they go?
The answer is nowhere. Tool descriptions travel in the request body's tools field, a structured thing with a dedicated place on the model's side. Copying a tool's parameter documentation into the system prompt is pure waste — and worse than waste, because if the two versions ever diverge you do not know which one the model believed.
So what tool-related content belongs in the system prompt? Only discipline, never documentation:
- Documentation is "
edit_filetakes path, old_string and new_string" — that belongs to the schema. - Discipline is "read the original before changing a file" — that belongs to the system prompt.
The criterion is handy: could this sentence go into some tool's description? If yes, put it there; if not (because it spans tools, or is about order and attitude), it goes into the system prompt.
One more rule today, paired with day seven's sentence: rules are not history. The session log restores what was said; instruction files are the rules as they are now. If you changed the project instructions yesterday and resume an old session today, the new rules must apply — otherwise two sessions in one repository work under two rule sets, invisibly from the terminal. The lab's approach: on recovery, replace the replayed old system message with a freshly loaded one, and print a line when it changed.
Making it explainable: one command that prints where each section came from
Now today's acceptance behavior, and today's most valuable piece of engineering.
The system prompt is the one part of the whole context that the user cannot see yet is in effect every turn. What does it look like when wrong? The model inexplicably ignores instructions. And the user has nothing to inspect — they do not know how many tiers of files exist, which tier won, or whether an import quietly failed.
So the lab adds /context, laying the whole thing out, and prints it once at startup too (something permanent should be visible by default rather than hidden behind a command):
system prompt: 4 sections, 699 characters (about 518 tokens); the three instruction tiers are
273 characters (about 177 tokens, 3% of the instruction budget)
1 base built-in base 426 chars - about 341 tokens
2 user work/home/AGENTS.md 37 chars - about 30 tokens
3 project work/repo/AGENTS.md 119 chars - about 79 tokens (imports 2 files: work/repo/.mca/assert.md, work/repo/.mca/testing.md)
4 directory work/repo/src/AGENTS.md 117 chars - about 68 tokens
1 conflict (more specific goes later, the later wins):
test command final value "node --test test/calc.test.js", from work/repo/src/AGENTS.md
overridden: "npm test" from work/home/AGENTS.md
overridden: "node --test" from work/repo/AGENTS.mdThese numbers are reproducible under MOCK=1 — character counts, section counts, conflict counts and token estimates depend only on file content. Not reproducible: the session id, elapsed time, and actual usage in real mode.
Three implementation decisions, continuing yesterday's discipline:
- The accounting goes to the terminal, not the model, for the same reason as yesterday's reference accounting.
- The budget excludes the base. The base is our own fixed cost, and counting it into "how many more rules can the user write" makes the percentage unreadable.
- Report only this path. How the total is divided between the system prompt, instruction files, memory, history and references is still day twelve's subject. With per-path ledgers, that day has something to divide.
Finally, the criterion question: how do you prove the three tiers really took effect rather than the model merely saying so? Look at the tool call's argument values. All three tiers in the lab write a "test command": user level says npm test, project level node --test, directory level node --test test/calc.test.js. The command it executes carries test/calc.test.js — arguments are hard evidence, and talk is not. Turning "the rule took effect" into an assertable argument value is this course's usual offline-script technique, used repeatedly in the days ahead.
Source Reading
Hands-On Lab
Today leaves five exercises, three of which are "looks more thorough, is actually worse" traps: using one set for both cycles and duplicates (a diamond import gets misreported), hoisting project instructions above the base "so the model sees them first," and recording conflicts without resolving them. The starter passes six of fifteen unmodified.
No child processes and no network today. The three instruction tiers and several deliberately broken examples are created by the lab under work/, and not one byte is written to your real home directory.
- Implement three-tier discovery: home directory, the repository root found by walking up to a marker, and walking down from the root to the start directory; outermost first, deduplicated by real path.
- Implement
@importexpansion with a depth cap and cycle detection — remember cycles look at the chain and duplicates at whether it was imported, two sets for two things. - Merge as base, user, project, directory, and actually delete overridden instruction lines from the text.
- Implement
/context: section count, each section's source and character count, estimated tokens, percentage of the instruction budget, the conflict table, and a message per bad import. - Run the self-test:
MOCK=1 SELFTEST=1 pnpm startshould print 15/15 passed. Section counts, character counts, token estimates and conflict counts are reproducible; the session id and elapsed time are not.
Acceptance is five ticks: the self-test prints 15/15 passed; the three tiers are discovered in order with a symlink counted once; the four bad imports each give a plain sentence without voiding the file; /context prints sources, characters, estimated tokens, budget percentage and the conflict table; and after the three-way conflict the model runs the most specific tier's test command.
Interview Questions
Today's three questions test layered-configuration design judgment, not "how to read a file":
- For layered project instruction files, how is priority set? Who wins on conflict?
- If instruction files can import each other, what safety limits would you add?
- How do you make the final system prompt explainable to the user? Why is that worth doing?
Full bilingual prompts, analyses and key points are in this course's day-nine question bank. Question three is easiest to answer as "just log it" — few can explain that an unexplainable system prompt leaves the user and the model speaking from different facts.
Checklist and Tomorrow
- I can state each tier's scope, and why the repository root must be found by walking up to a marker
- I know what deduplicating by real path solves, and the two harms of the same content appearing twice
- I can state the difference between cycle and duplicate detection, and what one set misreports a diamond import as
- I can name the four things imports must block, and why one bad line must not void a file
- I can explain why the base goes first, and why overridden instruction lines must really be deleted
- I know that tool documentation belongs to the schema and tool discipline to the system prompt, judged by whether the sentence could go in a tool description
- I can name the numbers
/contextshould print, and why rules are not history
Tomorrow is D10, "Task Checklists and Self-Planning: a todo Tool, Progress Rendering, and Catching a Spin Early." Today gave it rules — something fixed that applies every turn. Tomorrow gives it a checklist — something it maintains itself that changes every turn. The two pair up: rules say how work is done here, the checklist says how far this piece of work has got. Instruction files come before checklists because instructions are what we write for it and the checklist is what it writes for itself — clean up the "context we supply" path first, so tomorrow's "context it generates" has a counterpart.
Interview questions
For layered project instruction files, how do you decide precedence, and who wins on a conflict?分层的项目指令文件,优先级怎么定?冲突时谁赢?
Common in ChinaCommon overseasBasic#layered-config#system-promptHow to reason about it · think before answering
- This tests whether you have actually implemented layered configuration. Stopping at more specific wins invites two follow-ups immediately: how do you measure specific, and does the losing rule still appear in the final result?
- How to break it down: enumerate the layers and their scopes. Usually three — user level (my own preferences, valid in any repository), project level (this repository's rules, binding on everyone who enters), directory level (special rules for this patch of code). Scope narrows as you go, and that order is the precedence. You do not need a numeric priority field; the moment one exists, someone writes 999 and nobody dares touch it.
- The conclusion is two sentences: more specific goes later, and on a conflict the later one wins. Three non-obvious decisions follow. First, sort during discovery (directory layers outermost first) so merging never re-sorts. Second, the overridden rule must actually be removed from the final prompt — leaving both and letting the model choose means you do not know which it took, while your report says the later one won; a report that disagrees with what you actually sent is worse than no report. Third, conflicts you resolve automatically must be machine-recognizable in shape, such as single-line key-value directives; two contradictory prose paragraphs are not recognizable, so do not pretend to handle them — those can only be caught by a human reading the source listing.
- Volunteer two discovery pitfalls, because they are the implemented-it-before kind. The repository root must be found by walking up for a marker file (a VCS directory or a package manifest), stopping at the first hit, and falling back to the start directory — walking all the way to the filesystem root means a user running in a temp directory makes you read a world-writable path whose content lands in the system prompt. And discovery results must be deduplicated by real path, because symlinking the project file into a subdirectory is common; without dedup the same content appears twice and the model hesitates between two equally authoritative copies.
- Likely follow-up: why override rather than merge? Because instructions are natural language, and two natural-language rules have no reliable merge semantics. List-shaped things (a set of forbidden directories, say) can be unioned, but that is a field explicitly declared as a list, not a sentence. Override what should be overridden, union what you first defined as a list, and never blend the two mechanisms.
分析过程 · 先想清楚再作答
- 这题在考「你有没有真的实现过分层配置」。答「越具体的优先」就停下来的人会立刻被追问:那『具体』是怎么量的?冲突了之后,输的那条还在不在最终结果里?
- 怎么拆:先把层数与作用域列清楚。一般是三层——用户级(我这个人的偏好,走到哪个仓库都成立)、项目级(这个仓库的规矩,进来的人都该遵守)、目录级(这一片代码的特殊规矩)。作用域从宽到窄,**顺序就是优先级**,不需要再引入一个 priority 数字字段——数字字段一旦有了,就会有人写 999,然后谁都不敢改。
- 结论是两句话:**越具体的越靠后,冲突时后者赢。** 落地时有三个不那么显然的决定。一、发现阶段就把顺序排好(目录级要外层在前),合并阶段不再排第二次。二、**被覆盖的那条必须真的从最终结果里删掉**,不能两条都留着让模型自己挑——它同时看到两条矛盾的规矩时,你不知道它挑了哪一条,而你给用户的报告写的是『后者赢』,报告和实际发出去的东西不一致比没有报告更糟。三、能自动消解的冲突必须是机器能认出来的形状(比如只认独占一行的『键:值』),两段散文互相矛盾机器认不出来,就不该假装能处理,那种只能靠人看一眼来源清单发现。
- 还要主动讲两个发现阶段的坑,因为它们是「实现过才知道」的:**仓库根必须靠标记文件往上找**(`.git` 或包清单),找到第一个就停,没找到就把启动目录当根——一路上溯到文件系统根的话,用户在临时目录里跑一次,你就会去读一个谁都能写的路径,而它的内容会进 system prompt。**发现结果要按真实路径去重**,因为把项目级文件软链到子目录很常见,不去重同一份内容会在 prompt 里出现两遍,模型会在两份「同样权威」的副本之间犹豫。
- 可预期的追问:为什么不做成「合并」而是「覆盖」?因为指令是自然语言,两条自然语言没有可靠的合并语义。数组类的东西(比如禁止访问的目录清单)可以取并集,但那是一个明确声明为列表的字段,不是一句话——**该覆盖的覆盖、该取并集的先把它定义成列表**,混在一起做才是灾难。
Key points
- Three scopes from broad to narrow — user, project, directory — and the order is the precedence; no numeric priority field
- Two rules: more specific goes later, and the later one wins on conflict
- The overridden rule must be removed from the final prompt, not left alongside for the model to pick
- Auto-resolved conflicts must have a machine-recognizable shape; do not pretend to resolve contradictory prose
- Find the repo root by walking up for a marker, falling back to the start directory; dedupe discoveries by real path
答题要点
- 三层作用域从宽到窄:用户级、项目级、目录级;顺序就是优先级,不引入 priority 数字字段
- 规则两句话:越具体越靠后,冲突时后者赢
- 被覆盖的那条必须真的从最终 prompt 里删掉,不能两条都留着让模型挑
- 能自动消解的冲突必须是机器认得出的形状;散文矛盾不假装能处理
- 仓库根靠标记文件往上找、找不到就用启动目录;发现结果按真实路径去重
If instruction files can import one another, what safety limits would you add?指令文件支持互相导入,你会加哪些安全限制?
Common in ChinaCommon overseasIntermediate#imports#safety-limitsHow to reason about it · think before answering
- The signal here is naming four limits and explaining why two of them are commonly collapsed into one. Answering only prevent cycles misses three.
- How to break it down: enumerate by what can go wrong — runaway depth, cycles, duplicates, and escaping the allowed root — one limit each.
- Depth limit: three levels is enough; deeper means the author should refactor rather than you should support it, and with no limit a malicious or accidental long chain makes loading scale with chain length on every single request. Cycle detection: the test is whether the target is in the current import chain, and the chain pops when a level exits. Duplicate imports: the test is whether it was imported anywhere in this expansion, a set that never pops, and a hit is skipped with a note. Root boundary: the resolved absolute path must stay inside the allowed root, and a user-level file may only import from the home directory — a repository's instruction file must not be able to pull in files from the user's home, which turns the project into a springboard.
- Separating cycles from duplicates is the real dividing line. Using one set is the easy wrong version, and its symptom is concrete: a diamond import — A imports B and C, both import D — gets reported as a cycle, while a genuine cycle only produces a message that explains nothing. Two sets, two jobs: one ordered so you can print the whole trail, one that only answers membership.
- Also cover failure handling, since it decides whether this is usable by others: one bad line must not invalidate the whole instruction file. Replace the offending line with a short parenthetical note in place, keep everything else in effect, and report the problem explicitly. Instruction files are written by other people and you cannot guarantee every line is right, and one typo bricking a repository is the worst design; but silently skipping is also wrong, because its symptom is the model quietly failing to honor a rule.
- Likely follow-up: cap the size of a single file? Yes, and more strictly than for one-off injections, because instructions are resident: two thousand characters of instructions resent over twenty rounds is twenty times the cost. Refusing to load with a stated reason beats truncating, since truncation makes the model miss a rule it believes it has seen.
分析过程 · 先想清楚再作答
- 这题的区分度在于你能不能说出**四条**,而且能说清其中两条为什么容易被写成同一条。只答「防循环」的人漏了三条。
- 怎么拆:按「会出什么事」列。深度失控、循环、重复、越界,各对应一条限制。
- **深度上限**:三层足够。再深说明作者该重构文件,而不是你该支持它;而且没有上限时一条恶意或手滑的长链会让加载变成 O(链长),每一轮请求都付一次。**循环检测**:判据是「当前这条导入链里有没有它」,链要在退出一层时弹出。**重复导入**:判据是「整次展开里导入过没有」,这个集合永不弹出,命中就跳过并说明。**根边界**:解析成绝对路径之后必须还在允许的根里面,而且用户级文件只能导入主目录里的东西——一个仓库的指令文件不该能把用户主目录里的文件拉进来,那是把项目当成了跳板。
- 循环与重复的区分是这题真正的分水岭。只用一个集合是最容易写出来的版本,它的症状很具体:菱形导入(甲同时导入乙和丙,乙丙都导入丁)会被报成循环,而真正的循环反而只得到一句说不清发生了什么的提示。**两个集合两件事:一个有顺序(能打出整条链),一个只回答在不在。**
- 还要讲失败处理,因为它决定这套东西能不能给别人用:**坏一行不许让整份指令失效。** 出问题的那一行换成一句括号说明留在原地,其余内容照常生效,但问题要显式报出来。指令文件是别人写的,你没法保证每一行都对,而「一个路径写错整个仓库不能用」是最糟的设计;反过来,静默跳过也不行——它的表现是模型莫名其妙少守了一条规矩。
- 可预期的追问:单个文件要不要限大小?要,而且要比一次性的引用严得多——指令是常驻的,一段两千字符的指令二十轮就重发了二十遍。超了直接不加载并说明原因,比截断诚实:截断会让模型少看到一条它以为自己看到了的规矩。
Key points
- Four limits: depth cap, cycle detection, duplicate skip, root boundary
- Cycles test the current import chain (ordered, popped on exit); duplicates test a set that never pops
- One shared set misreports diamond imports as cycles while giving real cycles a useless message
- The path test is that the resolved absolute path stays inside the root; user-level files must not be reachable from a project
- A bad line becomes an inline note and everything else stays in effect, but the problem must be reported explicitly
答题要点
- 四条限制:深度上限、循环检测、重复跳过、根边界
- 循环看「当前导入链」(有序、退出即弹出),重复看「整次展开里导入过没有」(永不弹出)
- 只用一个集合会把菱形导入误报成循环,而真正的循环得不到有用的提示
- 路径判据是解析成绝对路径后仍在根内;用户级文件不许从项目里被拉进来
- 坏一行换成一句说明留在原地,其余照常生效,但问题必须显式报出来
How do you make the final system prompt explainable to the user, and why is that worth doing?怎么让最终的 system prompt 对用户可解释?为什么这件事值得做?
Common in ChinaCommon overseasDeep dive#observability#prompt-assemblyHow to reason about it · think before answering
- This tests product-level engineering judgment and is easily answered as just log it. The signal is articulating the cost of being unexplainable and deriving from it which numbers to print.
- How to break it down: establish the system prompt's unique position — it is the only part of the context that the user never sees yet takes effect on every single request. Injections are things the user typed, history is what they said; only the system prompt is assembled behind their back.
- So when it is wrong, the symptom is the model inexplicably not following instructions, and the user has nothing to inspect: how many layers exist, which one won, whether an import silently failed. The essence of the cost is that the model and the user reason from different facts — the user believes all four layers are in effect, the model saw two, so its behavior looks unreasonable, and there is no vantage point from which to observe the gap.
- That derivation makes the output obvious: one command that prints the number of segments, each segment's source file, its characters and estimated tokens, its share of this lane's budget, plus a conflict table saying which directive finally applies and what it overrode, and finally one line per broken import. Resident context also deserves printing automatically at startup rather than hiding behind a command the user must think to type.
- Volunteer three implementation decisions. First, the accounting goes to the terminal, not the model — these numbers exist for human decisions and would just spend budget again. Second, the budget excludes the built-in base prompt, which is a fixed cost we wrote ourselves; counting it makes the percentage meaningless as an answer to can the user add two more rules. Third, report only this lane; how the total budget is divided across system prompt, instructions, memory, history, and injections is a layer up, and its precondition is exactly this per-lane accounting.
- Finally, verification, which is the bonus here: how do you prove the rules took effect rather than the model merely claiming so? Look at tool-call arguments. Give each of the three layers a different test command and assert that the command it ran carries the most specific layer's argument — arguments are hard evidence, prose is not. Turning rule took effect into an assertable argument value is the only dependable way to accept a feature like this.
- Likely follow-up: should users be able to read the full system prompt verbatim? Yes, but as a second-level view. The first level should be this source-annotated summary, because reading a long block of prose still leaves the user unable to tell which sentence came from which file and what overrode what.
分析过程 · 先想清楚再作答
- 这题在考产品级的工程判断,而且很容易答成「打个日志就行」。区分度在于你能不能说清**不可解释的代价**,再倒推出该打哪几个数。
- 怎么拆:先说清 system prompt 的特殊地位——它是整个上下文里唯一一段「用户看不见、但每一轮都在生效」的内容。引用是用户自己写的 @,历史是他说过的话,只有 system prompt 是拼出来的。
- 所以它错了的表现是「模型莫名其妙不听话」,而用户手里没有任何东西可查:不知道有几层文件、不知道哪一层赢了、不知道有没有一个导入悄悄失败了。**代价的本质是模型和用户基于不同的事实说话**——用户以为四层规矩都在,模型只看到两层,于是它的行为在用户看来毫无道理,而这个 gap 没有任何入口可以观测。
- 倒推出该打的东西就很清楚了:一条命令(本课叫它 `/context`),打出段数、每段来自哪个文件、多少字符、约多少 token、占本路预算的百分之几,以及冲突表——哪个键最终生效的是哪条、被覆盖的各是什么,最后是每一条坏导入的提示。常驻的东西还值得在**启动时自动打一遍**,而不是藏在一条要用户主动敲的命令后面。
- 三个实现决定要主动说:一、**报账打在终端,不打给模型**,这些数字是给人做决策的,塞给模型只是再花一遍预算。二、**预算不含内置基座**,基座是我们自己写死的固定成本,算进「用户还能不能多写两行规矩」这笔账里,那个百分比就看不懂了。三、**只报自己这一路**,总预算怎么在系统提示、指令、记忆、历史、引用之间分配是另一层的问题,但它的前提正是每一路都自己报账。
- 最后是验证问题,也是这题的加分项:怎么证明规矩真的生效了,而不是模型嘴上说说?**看工具调用的参数值。** 让三层各写一条不同的测试命令,然后断言它执行的那条带着最具体那一层的参数——参数是硬的,模型说什么都不算。把「规则生效了」变成一个可断言的参数值,是这类功能唯一靠得住的验收方式。
- 可预期的追问:那要不要允许用户直接看到完整的 system prompt 原文?要,但那是第二层入口。第一层应该是这份带来源的摘要——原文一大段读下来,用户仍然不知道哪句话来自哪个文件、谁覆盖了谁。
Key points
- The system prompt is the only context the user cannot see yet applies every round; being unexplainable makes model and user reason from different facts
- One command printing segment count, each source, characters, estimated tokens, share of the lane budget, plus conflicts and broken imports
- Print resident context automatically at startup instead of hiding it behind a command
- Accounting goes to the human, not the model; the budget excludes the base prompt; report only this lane
- Verify with tool-call arguments: give each layer a different test command and assert the most specific one ran
答题要点
- system prompt 是唯一「用户看不见但每一轮都生效」的上下文,不可解释的代价是模型与用户基于不同事实说话
- 一条命令打出段数、每段来源、字符数、估算 token、占本路预算比例,以及冲突表与坏导入提示
- 常驻的东西启动时自动打一遍,不要只藏在一条要用户主动敲的命令后面
- 报账打给人不打给模型;预算不含内置基座;只报自己这一路
- 验证靠工具调用的参数值:让三层写不同的测试命令,断言它执行的是最具体那一条