项目指令文件:三层加载、导入展开与 system prompt 的合并顺序
让 Agent 一进仓库就知道规矩:实现用户级、项目级、目录级三层指令文件的发现与加载,支持文件之间的导入,定义确定的合并顺序与冲突规则,并让用户能一条命令看清最终 system prompt 是怎么拼出来的。
今日目标
- 能实现三层指令文件的发现与加载,并说清各层的作用域与优先级
- 能实现带循环检测与深度上限的导入展开
- 能定义可解释的合并顺序,并提供一条命令让用户看到最终结果的来源
昨天解决的是「这一次看什么」,今天解决的是「每一次都该知道什么」。读完回到页面顶部把三条目标勾掉。
小白版讲解
团队规章:新人第一天该读哪几页,谁写的算数
那个新人已经能干活了,也知道该看哪个文件了。但你有没有发现,每天早上你都在重复同样几句话:我们不写分号;测试用 node --test 跑,别用 npm test;改代码之前先跑一次确认它是红的;断言只用 node:assert/strict。
说到第五遍你就会明白,这些话不该每天说一遍——它们该写进一份规章,挂在他工位上。
mca 现在的规章只有一句写死在代码里的系统提示。要让它变成「一进仓库就知道规矩」,规章得住在文件里。而一旦规章住进文件,一个问题立刻冒出来:这份规章该由谁写?
答案是三个人都要写,而且他们说的话作用域不一样:
- 我这个人有偏好:回答用中文,别复述我的问题。这条走到哪个仓库都成立。
- 这个仓库有规矩:代码风格、测试怎么跑、哪些目录别碰。进这个仓库的人都该遵守。
- 这一片代码还有特殊规矩:
src/下面的东西改完必须立刻跑一次那条更窄的测试命令。
三份规章,三个作用域,从宽到窄。于是今天要做的事情就有了形状:三层指令文件,各自被发现,按确定的顺序合并成一段 system prompt。 顺序只有一句话——越具体的越靠后,冲突时后者赢。
这条规则听起来平淡,但它是今天全部工程量的源头。因为「后者赢」意味着前面那条要真的消失,而不是两条都留着让模型自己挑;意味着用户得有办法看到「到底是哪一条赢了」;也意味着这三份文件之间还会互相导入,而导入会带来循环、深度和越界三类麻烦。
三层从哪找:主目录、仓库根、当前目录往上溯
发现这一步看着最简单,坑却最集中。三层各自的找法都不一样。
用户级最直接:用户主目录下那个固定名字的文件。但在练习环境里绝不能真的去读写用户主目录——那里可能已经有别的工具的同名文件,往里写东西更是不可接受。本实验把它做成一个环境变量 MCA_HOME,默认落在 work/home 下;生产实现里换成 os.homedir() 就行,发现逻辑一行都不用改。
项目级要先找到仓库根,而找法只有一种是对的:从当前目录往上走,看哪一级有标记文件(本实验认 .git 或 package.json),找到第一个就停。
两种常见的错法各有代价。一种是「往上找三层」——用户的目录深度不由你决定。另一种更危险:一路上溯直到文件系统根。用户在 /tmp 里跑一次,你就会去读 /AGENTS.md——一个谁都能写的路径,而它的内容会进你的 system prompt。所以没找到标记就把启动目录当成根,宁可少读一层。
目录级是从仓库根往下走到启动目录,每一级都看一眼。注意方向:找的时候可以从里往外,但结果必须外层在前,因为那就是合并顺序。发现阶段把顺序排好,合并阶段就不用再排一次。
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
// 软链接去重:同一个真实文件被两条路径发现时只算一次
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))
// 从仓库根往下走到启动目录:外层在前,这个顺序就是优先级
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]:
"""三层从宽到窄。用 realpath 当键去重,软链接指向同一个文件时只算一层。"""
found: list[Found] = []
seen: set[Path] = set()
async def push(layer: str, file: Path) -> None:
if not file.exists():
return
real = file.resolve() # resolve 已经把软链接跟到底了
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)
# relative_to 之后逐段拼回去:外层在前
for part in start.resolve().relative_to(root).parts:
root = root / part
await push("directory", root / INSTRUCTION_FILE)
return found那个 seen 集合值得单独说:按真实路径去重。把项目级指令软链到某个子目录是很常见的做法,不去重的话同一份内容会在 system prompt 里出现两遍。两遍的坏处不只是花钱——模型看到两份「同样权威」的副本时,会在它们之间犹豫;如果其中一份因为预算被截断了,它甚至会认为两者有差别。
导入展开:相对路径、循环引用、深度上限
规章写长了就要拆。所以指令文件之间需要一个导入语法,本实验用独占一行的 @import ./testing.md,路径相对引用它的那个文件。
为什么不复用昨天那个 @:昨天那个是用户在对话里临时贴资料,坏了给一行提示就够;今天这个是作者在文件里做长期拆分,坏了意味着每一轮请求都少一段规矩,必须显式报出来。
导入要挡四件事,而其中两件常被写成同一件——这是今天最值得记住的一个区分:
// chain 是「当前这条导入链」,退出一层就 pop —— 用来判循环
// seen 是「整次展开里导入过的文件」,永不 pop —— 用来判重复
if (depth + 1 > maxDepth) {
problems.push(`${short(from)} 的导入已经是第 ${depth + 1} 层,超过深度上限 ${maxDepth}`)
return `(导入未展开:超过了深度上限 ${maxDepth})`
}
if (chain.includes(absolute)) {
// 提示里要把整条链打出来,否则用户完全无从下手
problems.push(`发现循环导入:${chain.map(short).join(' → ')} → ${short(absolute)},已跳过`)
return `(循环导入已跳过:${short(absolute)})`
}
if (seen.has(absolute)) {
problems.push(`${short(absolute)} 已经被导入过一次,这次跳过(重复导入只算一次)`)
return `(重复导入已跳过:${short(absolute)})`
}
seen.add(absolute)# chain 用列表(有顺序,能打出整条链),seen 用集合(只问在不在)
if depth + 1 > max_depth:
problems.append(f"{short(src)} 的导入已经是第 {depth + 1} 层,超过深度上限 {max_depth}")
return f"(导入未展开:超过了深度上限 {max_depth})"
if target in chain:
trail = " → ".join(short(p) for p in (*chain, target))
problems.append(f"发现循环导入:{trail},已跳过")
return f"(循环导入已跳过:{short(target)})"
if target in seen:
problems.append(f"{short(target)} 已经被导入过一次,这次跳过(重复导入只算一次)")
return f"(重复导入已跳过:{short(target)})"
seen.add(target)循环看的是导入链,重复看的是导入过没有。 只用一个集合是最容易写出来的错版本:菱形导入——甲同时导入乙和丙,乙和丙都导入丁——会被报成循环,而真正的循环反而只得到一句说不清发生了什么的提示。本实验的自检里这两种情况各有一项,就是为了逼出这个区分。
另外两件事一句话各自说清:深度上限本实验定三层,再深说明作者该重构文件,而不是你该支持它;根边界的判据只能是「解析成绝对路径之后还在不在根里面」,而且用户级文件只能导入主目录里的东西——一个仓库的指令文件不该能把用户主目录里的文件拉进来,那是把项目当成了跳板。
最后一条纪律,四种坏情况共享:坏一行不许让整份指令失效。 出问题的那一行换成一句括号说明留在原地,其余内容照常生效。理由很实际——指令文件是别人写的,你没法保证它每一行都对,而「一个逗号写错整个仓库不能用」是最糟的设计。
合并顺序:越具体越靠后,冲突时后者赢
现在把三层拼起来。顺序是:
内置基座(角色 + 工具纪律)→ 用户级 → 项目级 → 目录级(外层到内层)一个很自然的反对意见是:项目规矩最重要,应该放在最前面让模型先看到。这个想法有两个代价。
第一个是自相矛盾。 我们对模型宣布的规则是「越靠后越具体,冲突时听后面的」。基座是最泛的那一段,它排在最后,规则和实现就打起来了。
第二个是钱。 每一轮请求都要把整个消息数组重发一次,而 prompt 的前缀是有机会被网关缓存的——第一天那张实测表里的 cached_tokens 就是它。把每次都可能变的指令放在最前面,等于每一轮都换一个前缀。把最不会变的那一段放在最前面,是唯一不花钱的优化。
顺序定了,接下来是「后者赢」怎么落地。这里有个实现上的关键决定:被覆盖的那条要真的从正文里删掉。
只把冲突记下来、正文里两条都留着,是最省事也最糟的做法:模型同时看到「测试命令:npm test」和「测试命令:node --test test/calc.test.js」,你不知道它会挑哪一条,而你打给用户的报告写的是「后者赢」。报告和实际发出去的东西不一致,比没有报告更糟。
要能删,就得先能识别。本实验只认一种形状的指令:独占一行的 - 键:值。散文照常留着,但只有这种形状参与冲突判定。这是个有意的取舍——能自动消解的冲突必须是能被机器认出来的冲突。至于两段散文互相矛盾(一处写「尽量简洁」,另一处写「解释要详细」),机器认不出来,也就不该假装能处理;那种冲突只能靠人读一遍 /context 发现。
指令与工具描述抢位置:system prompt 里谁在前面
有个问题绕不开:工具那么多描述,该放在 system prompt 的哪里?
答案是根本不放。工具描述走请求体里的 tools 字段,它是一份结构化的东西,模型那边有专门的位置接它。把工具的参数说明再抄一份进 system prompt,是纯粹的浪费——而且比浪费更糟,因为两份说法一旦有出入,你不知道模型信了哪一份。
那 system prompt 里该有什么和工具有关的内容?只有纪律,不是说明:
- 说明是「
edit_file有 path、old_string、new_string 三个参数」——归 schema。 - 纪律是「改文件之前必须先读一遍原文」——归 system prompt。
这条分工的判据很好用:这句话能不能写进某个工具的 description 里? 能,就写在那儿;不能(因为它跨多个工具,或者它讲的是顺序和态度),才进 system prompt。
顺带定一条今天的口径,和第七天那句话配套:规矩不属于历史。 会话日志恢复的是「说过的话」,指令文件是「现在的规矩」。你昨天改了项目指令,今天接上一条老会话,生效的必须是新规矩——否则同一个仓库里两条会话按两套规矩干活,而这件事从终端上完全看不出来。本实验的做法是:恢复时把重放出来的那条旧 system 消息换成当下重新加载的,并且在变了的时候打一行提示。
让它可解释:一条命令打印每一段来自哪个文件
现在是今天的验收现象,也是今天最有价值的一段工程。
system prompt 是整个上下文里唯一一段「用户看不见、但每一轮都在生效」的内容。它错了的表现是什么?模型莫名其妙不听话。 而用户手里没有任何东西可查——他不知道有几层文件、不知道哪一层赢了、不知道有没有一个导入悄悄失败了。
所以本实验加了一条 /context,把这一段完全摊开,启动时还会自动打一遍(常驻的东西默认可见,比藏在一条命令后面好):
system prompt 共 4 段、699 字符(约 518 token);其中三层指令 273 字符(约 177 token,占指令预算 3%)
1 基座 内置基座 426 字符 · 约 341 token
2 用户级 work/home/AGENTS.md 37 字符 · 约 30 token
3 项目级 work/repo/AGENTS.md 119 字符 · 约 79 token(含导入 2 个文件:work/repo/.mca/assert.md、work/repo/.mca/testing.md)
4 目录级 work/repo/src/AGENTS.md 117 字符 · 约 68 token
冲突 1 处(越具体越靠后,后者赢):
测试命令 最终生效「node --test test/calc.test.js」,来自 work/repo/src/AGENTS.md
被覆盖:work/home/AGENTS.md 的「npm test」
被覆盖:work/repo/AGENTS.md 的「node --test」这些数字在 MOCK=1 下是可复现的——字符数、段数、冲突条数、token 估算都只取决于文件内容。不可复现的是会话 id、耗时,以及真实模式下的实际用量。
三个实现决定,都是昨天那条纪律的延续:
- 报账打在终端,不打给模型。 和昨天引用的报账同一个道理。
- 预算不含基座。 基座是我们自己写死的固定成本,把它算进「用户还能不能多写两行规矩」这笔账里,那个百分比就看不懂了。
- 只报自己这一路。 总预算怎么在系统提示、指令文件、记忆、历史、引用之间分配,仍然是第十二天的题目。有了各路的账,那一天才有东西可分。
最后是判据问题:怎么证明这三层真的生效了,而不是模型嘴上说说?看工具调用的参数值。 本实验里三层都写了「测试命令」,用户级说 npm test、项目级说 node --test、目录级说 node --test test/calc.test.js。它执行的那条命令带着 test/calc.test.js——参数是硬的,说什么都不算。把「规则生效了」变成一个可断言的参数值,是这门课离线剧本的常用手法,后面几天还会反复用到它。
源码导读
动手实验
今天挖了五个练习点,其中三个是「看着更周到、实际更糟」的陷阱:用一个集合同时判循环和重复(菱形导入会被误报)、把项目指令提到基座前面「让模型先看到」、冲突只记录不消解。起点代码原样跑是十五项里过六项。
今天不需要子进程也不需要网络。三层指令文件与几个专门用来看错误的坏例子都由实验自己造在 work/ 下面,一个字节都不会写到你真实的用户主目录。
- 实现三层发现:主目录、靠标记文件往上找到的仓库根、从仓库根往下走到启动目录;顺序外层在前,并按真实路径去重。
- 实现
@import展开,补齐深度上限与循环检测——注意循环看导入链、重复看导入过没有,这是两个集合两件事。 - 按「基座 → 用户级 → 项目级 → 目录级」合并,并把被覆盖的指令行真的从正文里删掉。
- 实现
/context:段数、每段来源与字符数、估算 token、占指令预算比例、冲突表,以及每一条坏导入的提示。 - 跑自检:
MOCK=1 SELFTEST=1 pnpm start应该打印15/15 通过。段数、字符数、token 估算、冲突条数都是可复现的;会话 id 与耗时不是。
验收看五条勾:自检 15/15 通过;三层按顺序被发现且软链接只算一层;四种坏导入各有一句人话且都不让整份指令失效;/context 打出来源、字符数、估算 token、占预算比例与冲突表;三层冲突之后,模型执行的是最具体那一层的测试命令。
面试题
今天三道题,考的是分层配置的设计判断,不是「怎么读文件」:
- 分层的项目指令文件,优先级怎么定?冲突时谁赢?
- 指令文件支持互相导入,你会加哪些安全限制?
- 怎么让最终的 system prompt 对用户可解释?为什么这件事值得做?
完整的中英题干、分析过程与答题要点见本课面试题库的第九天。第三题最容易答成「打个日志就行」——能说清「不可解释的 system prompt 会让用户和模型基于不同的事实说话」的人不多。
检查清单与明日预告
- 能说出三层指令文件各自的作用域,以及仓库根为什么必须靠标记文件往上找
- 知道按真实路径去重解决的是什么问题,以及同一份内容出现两遍的两种坏处
- 能说清循环检测与重复检测的区别,以及只用一个集合会把菱形导入误报成什么
- 能说出导入要挡的四件事,以及「坏一行不让整份指令失效」为什么重要
- 能解释基座为什么在最前面,以及被覆盖的指令行为什么要真的删掉
- 知道工具说明归 schema、工具纪律归 system prompt,判据是那句话能不能写进某个工具的 description
- 能说出
/context该打哪几个数,以及为什么「规矩不属于历史」
明天是 D10《任务清单与自我规划:todo 工具、进度渲染与打转的早期发现》。今天给它的是规矩——一份不会变的、每一轮都生效的东西。明天给它的是清单——一份它自己维护、每一轮都在变的东西。两者正好是一对:规矩说「这里的活儿该怎么干」,清单说「这一件活儿干到哪了」。顺序上先做指令文件再做清单,是因为指令是我们写给它的,清单是它写给自己的——先把「外部给的上下文」这条链路收干净,明天那条「它自己产生的上下文」才有对照。
面试题库
分层的项目指令文件,优先级怎么定?冲突时谁赢?For layered project instruction files, how do you decide precedence, and who wins on a conflict?
国内高频海外高频基础#layered-config#system-prompt分析过程 · 先想清楚再作答
- 这题在考「你有没有真的实现过分层配置」。答「越具体的优先」就停下来的人会立刻被追问:那『具体』是怎么量的?冲突了之后,输的那条还在不在最终结果里?
- 怎么拆:先把层数与作用域列清楚。一般是三层——用户级(我这个人的偏好,走到哪个仓库都成立)、项目级(这个仓库的规矩,进来的人都该遵守)、目录级(这一片代码的特殊规矩)。作用域从宽到窄,**顺序就是优先级**,不需要再引入一个 priority 数字字段——数字字段一旦有了,就会有人写 999,然后谁都不敢改。
- 结论是两句话:**越具体的越靠后,冲突时后者赢。** 落地时有三个不那么显然的决定。一、发现阶段就把顺序排好(目录级要外层在前),合并阶段不再排第二次。二、**被覆盖的那条必须真的从最终结果里删掉**,不能两条都留着让模型自己挑——它同时看到两条矛盾的规矩时,你不知道它挑了哪一条,而你给用户的报告写的是『后者赢』,报告和实际发出去的东西不一致比没有报告更糟。三、能自动消解的冲突必须是机器能认出来的形状(比如只认独占一行的『键:值』),两段散文互相矛盾机器认不出来,就不该假装能处理,那种只能靠人看一眼来源清单发现。
- 还要主动讲两个发现阶段的坑,因为它们是「实现过才知道」的:**仓库根必须靠标记文件往上找**(`.git` 或包清单),找到第一个就停,没找到就把启动目录当根——一路上溯到文件系统根的话,用户在临时目录里跑一次,你就会去读一个谁都能写的路径,而它的内容会进 system prompt。**发现结果要按真实路径去重**,因为把项目级文件软链到子目录很常见,不去重同一份内容会在 prompt 里出现两遍,模型会在两份「同样权威」的副本之间犹豫。
- 可预期的追问:为什么不做成「合并」而是「覆盖」?因为指令是自然语言,两条自然语言没有可靠的合并语义。数组类的东西(比如禁止访问的目录清单)可以取并集,但那是一个明确声明为列表的字段,不是一句话——**该覆盖的覆盖、该取并集的先把它定义成列表**,混在一起做才是灾难。
How 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 数字字段
- 规则两句话:越具体越靠后,冲突时后者赢
- 被覆盖的那条必须真的从最终 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
指令文件支持互相导入,你会加哪些安全限制?If instruction files can import one another, what safety limits would you add?
国内高频海外高频进阶#imports#safety-limits分析过程 · 先想清楚再作答
- 这题的区分度在于你能不能说出**四条**,而且能说清其中两条为什么容易被写成同一条。只答「防循环」的人漏了三条。
- 怎么拆:按「会出什么事」列。深度失控、循环、重复、越界,各对应一条限制。
- **深度上限**:三层足够。再深说明作者该重构文件,而不是你该支持它;而且没有上限时一条恶意或手滑的长链会让加载变成 O(链长),每一轮请求都付一次。**循环检测**:判据是「当前这条导入链里有没有它」,链要在退出一层时弹出。**重复导入**:判据是「整次展开里导入过没有」,这个集合永不弹出,命中就跳过并说明。**根边界**:解析成绝对路径之后必须还在允许的根里面,而且用户级文件只能导入主目录里的东西——一个仓库的指令文件不该能把用户主目录里的文件拉进来,那是把项目当成了跳板。
- 循环与重复的区分是这题真正的分水岭。只用一个集合是最容易写出来的版本,它的症状很具体:菱形导入(甲同时导入乙和丙,乙丙都导入丁)会被报成循环,而真正的循环反而只得到一句说不清发生了什么的提示。**两个集合两件事:一个有顺序(能打出整条链),一个只回答在不在。**
- 还要讲失败处理,因为它决定这套东西能不能给别人用:**坏一行不许让整份指令失效。** 出问题的那一行换成一句括号说明留在原地,其余内容照常生效,但问题要显式报出来。指令文件是别人写的,你没法保证每一行都对,而「一个路径写错整个仓库不能用」是最糟的设计;反过来,静默跳过也不行——它的表现是模型莫名其妙少守了一条规矩。
- 可预期的追问:单个文件要不要限大小?要,而且要比一次性的引用严得多——指令是常驻的,一段两千字符的指令二十轮就重发了二十遍。超了直接不加载并说明原因,比截断诚实:截断会让模型少看到一条它以为自己看到了的规矩。
How 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.
答题要点
- 四条限制:深度上限、循环检测、重复跳过、根边界
- 循环看「当前导入链」(有序、退出即弹出),重复看「整次展开里导入过没有」(永不弹出)
- 只用一个集合会把菱形导入误报成循环,而真正的循环得不到有用的提示
- 路径判据是解析成绝对路径后仍在根内;用户级文件不许从项目里被拉进来
- 坏一行换成一句说明留在原地,其余照常生效,但问题必须显式报出来
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
怎么让最终的 system prompt 对用户可解释?为什么这件事值得做?How do you make the final system prompt explainable to the user, and why is that worth doing?
国内高频海外高频深入#observability#prompt-assembly分析过程 · 先想清楚再作答
- 这题在考产品级的工程判断,而且很容易答成「打个日志就行」。区分度在于你能不能说清**不可解释的代价**,再倒推出该打哪几个数。
- 怎么拆:先说清 system prompt 的特殊地位——它是整个上下文里唯一一段「用户看不见、但每一轮都在生效」的内容。引用是用户自己写的 @,历史是他说过的话,只有 system prompt 是拼出来的。
- 所以它错了的表现是「模型莫名其妙不听话」,而用户手里没有任何东西可查:不知道有几层文件、不知道哪一层赢了、不知道有没有一个导入悄悄失败了。**代价的本质是模型和用户基于不同的事实说话**——用户以为四层规矩都在,模型只看到两层,于是它的行为在用户看来毫无道理,而这个 gap 没有任何入口可以观测。
- 倒推出该打的东西就很清楚了:一条命令(本课叫它 `/context`),打出段数、每段来自哪个文件、多少字符、约多少 token、占本路预算的百分之几,以及冲突表——哪个键最终生效的是哪条、被覆盖的各是什么,最后是每一条坏导入的提示。常驻的东西还值得在**启动时自动打一遍**,而不是藏在一条要用户主动敲的命令后面。
- 三个实现决定要主动说:一、**报账打在终端,不打给模型**,这些数字是给人做决策的,塞给模型只是再花一遍预算。二、**预算不含内置基座**,基座是我们自己写死的固定成本,算进「用户还能不能多写两行规矩」这笔账里,那个百分比就看不懂了。三、**只报自己这一路**,总预算怎么在系统提示、指令、记忆、历史、引用之间分配是另一层的问题,但它的前提正是每一路都自己报账。
- 最后是验证问题,也是这题的加分项:怎么证明规矩真的生效了,而不是模型嘴上说说?**看工具调用的参数值。** 让三层各写一条不同的测试命令,然后断言它执行的那条带着最具体那一层的参数——参数是硬的,模型说什么都不算。把「规则生效了」变成一个可断言的参数值,是这类功能唯一靠得住的验收方式。
- 可预期的追问:那要不要允许用户直接看到完整的 system prompt 原文?要,但那是第二层入口。第一层应该是这份带来源的摘要——原文一大段读下来,用户仍然不知道哪句话来自哪个文件、谁覆盖了谁。
How 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 是唯一「用户看不见但每一轮都生效」的上下文,不可解释的代价是模型与用户基于不同事实说话
- 一条命令打出段数、每段来源、字符数、估算 token、占本路预算比例,以及冲突表与坏导入提示
- 常驻的东西启动时自动打一遍,不要只藏在一条要用户主动敲的命令后面
- 报账打给人不打给模型;预算不含内置基座;只报自己这一路
- 验证靠工具调用的参数值:让三层写不同的测试命令,断言它执行的是最具体那一条
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