数据截至 (上游 commit 9965cfc0dafd)
上下文控制:instructions、按需 skills、subagents
30 秒导读: 一个 agent 跑得好不好,关键看「模型每一回合到底看到了什么」。eve 给你三档杠杆:
instructions是永远在场的系统提示;skills/默认不进提示,模型按需用load_skill拉进来;subagents干脆把一整段活委派给一个有独立提示、工具、沙箱、状态的子 agent。本章讲这三档怎么实现。
本章只讲「eve 如何控制模型每回合看到什么」。沙箱的信任边界细节见 05-channels-connections-security; 默认 harness 的 agent 循环与 compaction 见 03-harness-tool-loop;文件系统如何被发现、编译成 manifest 见 01-filesystem-discovery。
1. 这是什么(零基础也能懂)
一句话定义: 上下文控制 = 决定「模型这一回合的提示里塞了什么、没塞什么」的那套规则。
一个 agent 的「智力」很大程度上是被它看到的上下文决定的。塞太多——又贵、又慢、又容易被无关内容带偏; 塞太少——它不知道该怎么干。所以问题永远是:哪些东西每回合都得在,哪些只在需要时才出现,哪些根本不该进这个 agent 的提示。
eve 把答案分成三档,按「成本」由低到高排:
| 杠杆 | 什么时候在提示里 | 适合放什么 |
|---|---|---|
instructions(指令) | 每一回合都在(always-on) | agent 的永久身份、稳定契约 |
skills/(技能) | 默认不在,模型 load_skill 后才注入 | 可选的、长的操作手册 / playbook |
subagents(子 agent) | 完全不在本 agent 的提示里,另起一个隔离上下文 | 需要不同身份 / 不同工具面 / 想并行的整段活 |
一句话直觉/类比: 把它想成你桌上的三种东西——
instructions是贴在显示器上的便签:抬头就看见,永远在。skills是书架上的手册:平时收着,要用时才抽出来翻开。subagent是把活外包给另一个同事:他有自己的桌子、自己的工具、自己的便签,干完把结果交回来。
一个最小的目录长这样(eve 是「文件系统即接口」,见 01-filesystem-discovery):
agent/
├── agent.ts # 定义 agent(模型、limits 等)
├── instructions.md # always-on 系统提示
├── skills/
│ ├── forecast.md # flat skill(单文件)
│ └── research/SKILL.md # packaged skill(目录 + 附属文件)
└── subagents/
└── researcher/agent.ts # 声明式子 agent(必须 export description)
本节不出现底层代码。记住一句话就够:instructions 永远在、skills 按需进、subagent 整段外包。
2. 顶层全景(三档杠杆怎么落到一次模型调用)
这节讲:一次模型调用的系统提示到底是怎么拼出来的,三档杠杆各自在哪一步进场。
怎么读这张图: 从上到下是「拼一次模型提示」的顺序;左边是 always-on 的、右边是按需的。
┌─────────────────────────────────────────────┐
│ 一次模型调用的 system 提示 │
└─────────────────────────────────────────────┘
▲
┌──────────────────────────────┼──────────────────────────────┐
│ always-on(每回合都拼) │ 按需(只有触发了才进) │
▼ ▼ ▼
┌───────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ instructions │ │ Available skills 清单│ │ load_skill 的结果 │
│ 的 markdown │ │ (只列名+描述,不含正文)│ │ (某个 SKILL.md 正文)│
│ ① 永久身份 │ │ ② 路由提示 │ │ ③ 模型主动拉来的 │
└───────────────┘ └────────────────────┘ └────────────────────┘
│ ▲
│ 另一条路:把整段活交出去,不进本提示 │ 模型调用 load_skill 工具
▼ │
┌──────────────────────────────────────────────┐ │
│ subagent → 另起一个隔离子 session │ ← 子 agent 有自己 │
│ (独立 instructions / tools / sandbox / state)│ 的这整张图 │
└───────────────────────────────────── ─────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件(packages/eve/src/) |
|---|---|---|
| 提示拼装 | 把 instructions / workspace / connections / skills 清单拼成 base 提示 | runtime/prompt/compose.ts:44 composeRuntimeBasePrompt |
| skills 清单格式化 | 生成「Available skills」那一段(只列名+描述) | execution/skills/instructions.ts:26 formatAvailableSkillsSection |
load_skill 工具 | 从沙箱读出某个 SKILL.md 正文,作为工具结果返回 | runtime/framework-tools/skill.ts:115 SKILL_TOOL_DEFINITION |
| 每回合注入 | 把动态指令 + 技能公告塞进本回合 system 消息 | harness/tool-loop.ts:1288 |
| subagent 注册 | 把每个子 agent 降成一个模型可见工具 | runtime/subagents/registry.ts:87 createRuntimeSubagentRegistry |
| subagent 派发 | 真正起一个子 session 跑这个委派 | execution/dispatch-runtime-actions-step.ts |
| 深度上限 | 算当前委派深度、决定还能不能再委派 | harness/subagent-depth.ts:17 resolveSubagentDelegationLimit |
主线走一遍(高层): 一个回合开始 → harness 拼系统提示:instructions 正文 + workspace 提示 + connections + skills 清单(只有名字和描述) →
模型判断:这活要不要某个 skill?要 → 调 load_skill → eve 从沙箱读出那份 SKILL.md 正文当工具结果交回 → 模型拿到正文继续干。
若这活该整段外包 → 模型调某 个 subagent 工具 → eve 起一个隔离子 session,跑完把结果当工具结果交回。
3. 核心机制一:instructions —— 永远在场的系统提示
它要解决的小问题
agent 总有一份「不管这回合干啥都该遵守」的契约:它是谁、说话风格、硬规矩。这部分必须每回合都在。
思路:稳定 = 进 always-on,且尽量可被缓存
eve 的设计取舍是:把稳定的东西做成整个 session 不变的系统提示,这样上游的 prompt caching 能命中,省钱省延迟。 所以「永久身份」放 instructions,「会变的东西」(谁在调用、加载了哪个 skill)走别的通道,故意不去改 base 提示。
两种写法:markdown vs TypeScript
最简单的写法是 instructions.md,纯 markdown,就是一段稳定指令。
当你需要从「带类型的辅助函数 / 库代码 / 构建期环境值」拼出这段提示时,改写成模块 instructions.ts:
// 示意,非源码:agent/instructions.ts
import { defineInstructions } from "eve/instructions";
import { buildInstructionsPrompt } from "./lib/prompts.js";
export default defineInstructions({
markdown: buildInstructionsPrompt(), // 在构建期算出最终 markdown
});
关键细节:模块版只在构建期跑一次。 defineInstructions 的契约写得很明确:模块化的静态指令在构建期执行一次,
编译器把产出的 markdown 收进 compiled manifest,运行时每个 session 直接用同一份,不会再跑这个模块
(public/definitions/instructions.ts:11 InstructionsDefinition、:23 defineInstructions)。
defineInstructions 还会给返回值打一个 brand 标记(INSTRUCTIONS_BRAND),让动态指令生命周期能校验「这个返回值确实是经过该 helper 的」
(public/definitions/instructions.ts:26)。
真实实现:instructions 怎么拼进 base 提示
base 提示由 composeRuntimeBasePrompt 拼:
// runtime/prompt/compose.ts:30 composeRuntimeBasePrompt(摘录)
return [
...createInstructionsPromptBlocks(input.instructions), // ① instructions 正文
...createWorkspacePromptBlocks(input.workspaceSpec), // ② workspace 浅提示
...(input.toolsAvailable ? [PARALLEL_ACTION_INSTRUCTION] : []),
...createConnectionsPromptBlocks(input.connections), // ③ connections
...createSkillsPromptBlocks(input.skills), // ④ skills 清单(只有名+描述)
];
注意函数名:composeRuntimeBasePrompt——「without flattening skills into always-on instructions」
(runtime/prompt/compose.ts:41 注释)。这一句是整章的题眼:skills 不会被压平进 always-on 指令,base 提示里只放 skills 的清单,不放正文。
instructions 那一块自己很朴素:trim 后非空才进,加一行标题 Instructions (<name>)
(runtime/prompt/compose.ts:57 createInstructionsPromptBlocks)。
动态指令:按调用者变的那一档
当「该给什么上下文」取决于谁在调用(团队 / 租户 / 套餐 / feature flag),静态 instructions 就不够了。
这时在 agent/instructions/ 里用 defineDynamic 写一个 resolver,按 ctx.session.auth 或 channel 元数据返回这个 session 的系统提示。
实现上,动态指令的产出不进 base 提示,而是按 session / turn 两个作用域存进 durable key,每回合由 tool-loop 重新拼进系统消息:
// context/dynamic-instruction-lifecycle.ts:59 buildDynamicInstructionMessages
const session = ctx.get(SessionDynamicInstructionsKey) ?? {};
const turn = ctx.get(TurnDynamicInstructionsKey) ?? {};
return [...Object.values(session).flat(), ...Object.values(turn).flat()]; // session 在前
每个 resolver 的输出替换它自己那一格(按 slug 键),分别落在 session.started / turn.started 对应的 durable key 上
(context/dynamic-instruction-lifecycle.ts:44 durableKeyForEvent)。返回值必须经 defineInstructions 打过 brand,否则被丢弃并报错
(:129)。指令默认产 system 消息;显式 role: "user" 的定义会被降成 user 角色消息、按回合注入(lowerInstruction,context/dynamic-instruction-lifecycle.ts:29-42;role 形状见 shared/instructions-definition.ts:11,定义注释 public/definitions/instructions.ts:13-22)。
4. 核心机制二:skills —— 默认不入提示,按需注入
它要解决的小问题
很多「操作手册」很长很有用,但只在特定任务才需要(发布清单、写 changelog、调研流程)。 如果把它们全塞进 always-on 提示,每回合都在为没用上的内容付费,还冲淡了真正重要的指令。
思路:progressive disclosure(渐进式披露)
eve 的做法是业界 Agent Skills 标准的同一套:先只广播「有哪些 skill、各自该在什么场景用」,正文留着不进提示;
当模型判断这回合确实需要,它主动把正文拉进来。 一份按这个标准写的 skill 可以原样移植过来(docs/skills.mdx:6)。
回合开始
│ base 提示里只有这一段清单(便宜):
│ Available skills
│ - forecast: 回答天气/温度前先用天气工具 (path: /workspace/skills/forecast/SKILL.md)
│ - research: 遇到陌生/含糊问题先取证再答 ...
▼
模型:这活匹配 research 吗?
├── 不匹配 → 照常干,正文从没进过提示(省了)
└── 匹配 / 用户点名 → 调 load_skill("research")
│
▼ eve 从沙箱读 /workspace/skills/research/SKILL.md
▼ 去掉 frontmatter,把正文当【工具结果】交回
模型拿到正 文,本回合起按它干
skill 的两种形态:flat 和 packaged
| 形态 | 长什么样 | 描述(路由提示)从哪来 |
|---|---|---|
| flat | 单个 skills/forecast.md | 可省 description frontmatter;省了就取正文第一行非空非围栏行(去掉 #/>/*/- 前缀) |
| packaged | skills/research/ 目录 + SKILL.md + references//assets//scripts/ | 必须带 description frontmatter(没有文件名 slug 可兜底) |
packaged 的妙处:那些附属文件(参考、脚本、素材)不进提示,它们出现在运行时 workspace 根下,模型要看就用普通的 bash/read_file 去翻
(docs/skills.mdx:32、docs/concepts/context-control.md:42)。这是 eve 一贯的取舍——把运行时文件放进 workspace,而不是灌进提示。
真实实现一:清单怎么生成
清单段由 formatAvailableSkillsSection 生成。这个函数的文档注释把整章最核心的设计写死了:
// execution/skills/instructions.ts:8 注释(摘录)
// All skills are always listed regardless of activation state. Active skill
// instructions are never injected into the system prompt — the model already
// has them from the `load_skill` tool result. This keeps the system
// prompt identical across the entire session, preserving prompt caching.
翻成白话:不管哪个 skill 激活了,清单永远是全量、且只有名+描述;激活后的正文从不回填进系统提示——因为模型早已经从
load_skill 的工具结果里拿到了。这样系统提示整个 session 不变,prompt caching 才能一直命中。
每一行的格式带上了 workspace 路径,方便模型直接去 bash 看附属文件:
// execution/skills/instructions.ts:40 formatAvailableSkillLine
return `- ${skill.name}: ${skill.description} (path: ${WORKSPACE_ROOT}/skills/${skill.name}/SKILL.md)`;
(WORKSPACE_ROOT = /workspace,见 runtime/workspace/types.ts:9。)
真实实现二:load_skill 工具怎么读正文
load_skill 是 eve 自带的 framework 工具。它的执行体很直接:从当前沙箱读出那份 SKILL.md,去掉 YAML frontmatter,返回纯 markdown:
// runtime/skills/sandbox-access.ts:37 loadSkillFromSandbox(摘录)
assertSafeSkillId(id); // 先做路径段安全校验
const sandbox = await requireSandboxSession(access);
const path = skillFilePath(id, "SKILL.md"); // /workspace/skills/<id>/SKILL.md
const instructions = await sandbox.readTextFile({ path });
if (instructions === null) { /* 抛 not-found,并把可用 skill 名列进错 误 */ }
return instructions.replace(FRONTMATTER_PATTERN, ""); // 去掉 frontmatter,只回正文
工具描述本身就在教模型怎么用:「按名加载一个可用 skill 的完整指令;这不是给 MCP connection 用的;加载会把指令加进当前回合」
(runtime/framework-tools/skill.ts:115 SKILL_TOOL_DEFINITION)。
两个值得记的坑/细节:
- id 必须是安全路径段。
assertSafeSkillId拒绝空串、含空白、.前缀、/、\、..、盘符 (runtime/skills/sandbox-access.ts:12)——因为这个 id 会被直接拼进/workspace/skills/<id>路径,防的是路径穿越。 - 把 connection 错认成 skill 会有专门提示。 如果模型拿一个 connection 名去
load_skill,eve 不只是报 not-found, 还会提示「那是个已安装的 connection,不是 skill,请用connection_search