Copilot 提示词链路详解:从敲键盘到屏幕出结果
本文用本工作区(
test_skills)里真实存在的文件做例子,逐阶段拆解一条完整的请求链路。可信度说明 :下面的 JSON 报文是按这类系统的通用架构示意,各家的字段名和细节会有差异;但"模型只接收文本 + 工具表"这一层架构是确定的,也是理解一切的钥匙。
第一部分 · 基础说明
在讲链路之前,先把三个最容易混淆的概念钉死。
1.1 模型(Model)
模型是一个函数:
输入:一串 token
输出:下一个 token 的概率分布
它没有 文件系统、没有目录、没有网络、没有"技能"、没有"记忆"。它唯一会的动作是:接着往下写。
所谓"AI 助手",是把模型的这个能力用外层程序包装出来的产物。
1.2 什么是 Skill
Skill = 一份写在文件夹里的说明书,程序把它登记成"菜单项",模型按需翻阅。
物理形态(本工作区真实路径):
.github/skills/week-plan/SKILL.md ← 说明书正文
.github/skills/week-plan/assets/xxx.md ← 附带的资产
它在.md 文件头部有一段 YAML(叫 frontmatter),程序只读这两行来做登记:
yaml
---
name: week-plan
description: 'USE WHEN: 用户要求制定/生成/调整「一周学习计划」......'
---
关键认知:
| 问题 | 答案 |
|---|---|
| 模型知道"skill"是什么吗? | ❌ 不知道 。skill 只是提示词里出现的一个普通英文单词 |
| 这个词谁定的? | 人。源自 Anthropic 的 Claude Skills,VS Code Copilot 采纳了这个叫法和目录约定 |
| 模型看到 skill 的什么? | 一开始只看到 name + description 两行字 |
| 正文什么时候进模型? | 模型主动调 read_file 之后,才作为工具结果被追加进请求 |
结论:skill 是"程序侧的目录约定",不是"模型侧的能力"。
1.3 什么是 Agent
Agent = 一个"角色定义":换了人设、换了工具权限、可以换模型。
物理形态:
.github/agents/plan-coach.agent.md
frontmatter 里最关键的是 tools------工具白名单:
yaml
tools: ['codebase', 'search', 'edit'] # 没有终端 → 物理上无法跑命令
Skill 和 Agent 的本质区别:
| Skill | Agent | |
|---|---|---|
| 回答 | 这类任务怎么做 | 由谁来做、能碰什么 |
| 工具权限 | 无控制力 | 核心就是控制工具 |
| 约束性质 | 文本建议(可以不听) | 工具白名单(物理拦截) |
| 类比 | 岗位 SOP 手册 | 岗位本岗 + 门禁卡 |
1.4 三个概念速查
| 概念 | 载体 | 触发方式 | 模型能"绕开"吗 |
|---|---|---|---|
| Prompt | .github/prompts/*.prompt.md |
只能手动 /名字 |
能(纯文本) |
| Skill | .github/skills/<名>/SKILL.md |
描述匹配,自动 | 能(纯文本) |
| Agent | .github/agents/*.agent.md |
手动切模式 / 当子代理 | 部分不能(工具被摘掉就真没有) |
| MCP | MCP server 配置 | 模型按需调用 | 不能(真接口) |
第二部分 · 具体例子
2.1 场景设定
用户在 Chat 里说:
帮我制定一周学习计划,主题 MySQL 索引原理,每天 60 分钟
2.2 工作区里的真实文件(完整内容)
① .github/skills/week-plan/SKILL.md
markdown
---
name: week-plan
description: 'USE WHEN: 用户要求制定/生成/调整「一周学习计划」「7天学习安排」「学习排期」「周计划」。按内置模板生成计划并写入 study.md。'
---
# 一周学习计划生成器
## 何时使用
用户提到"一周学习计划""7 天安排""学习排期""周计划"时启用本技能。
## 步骤
1. 读取 `assets/plan-template.md`,它是**必须遵守**的输出格式定义。
2. 收集缺失信息:学习主题、每天可投入时间。用户没给就默认 60 分钟。
3. 按模板生成 7 天计划。
4. **写入** `study.md`(覆盖写入)。
5. 回复里只给"写入确认 + 3 行摘要",不要重复整张表。
## 硬性规则
- 第 7 天固定为"复习 + 自测"。
- 每天的验收标准必须可客观判断(例:"能默写 B+ 树三层结构")。
- 禁止"了解""熟悉""掌握"这类无法验证的动词。
## 资产
- `assets/plan-template.md` --- 输出格式模板
② .github/skills/week-plan/assets/plan-template.md
markdown
# 一周学习计划:{{TOPIC}}
> 每天投入:{{MINUTES}} 分钟 | 生成时间:{{DATE}}
| Day | 主题 | 具体任务 | 验收标准 |
| --- | ---- | -------- | -------- |
| 1 | | | |
| 2 | | | |
| 3 | | | |
| 4 | | | |
| 5 | | | |
| 6 | | | |
| 7 | 复习 + 自测 | | |
## 打卡
- [ ] Day 1
- [ ] Day 2
- [ ] Day 3
- [ ] Day 4
- [ ] Day 5
- [ ] Day 6
- [ ] Day 7
③ .github/agents/plan-coach.agent.md
markdown
---
description: '学习计划教练:只处理学习计划相关请求,工具受限(不能跑命令、不能装依赖),适合被当作子代理调用。'
tools: ['codebase', 'search', 'edit']
---
# 角色
你是"学习计划教练"。你**只**处理学习计划相关的请求。
## 工作方式
1. 先一次性问清楚:学习目标、截止时间、每天可投入时间、当前水平。
2. 用 `search` / `codebase` 看看仓库里有没有旧计划,避免重复造。
3. 生成计划后写入 `study.md`。
4. 结尾给一句"如果只做一件事,先做哪件"的建议。
## 边界
- 不写代码、不执行命令、不安装依赖。超出范围就直说"这不在我的职责内"。
- 你的工具列表里没有终端,所以就算你想跑命令也跑不了------这是刻意设计的。
第三部分 · 完整调用逻辑(逐阶段)
模型服务 磁盘 .github/ Copilot 程序 你 模型服务 磁盘 .github/ Copilot 程序 你 #mermaid-svg-M2g9fXrze55vcJty{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M2g9fXrze55vcJty .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M2g9fXrze55vcJty .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M2g9fXrze55vcJty .error-icon{fill:#552222;}#mermaid-svg-M2g9fXrze55vcJty .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M2g9fXrze55vcJty .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M2g9fXrze55vcJty .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M2g9fXrze55vcJty .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M2g9fXrze55vcJty .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M2g9fXrze55vcJty .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M2g9fXrze55vcJty .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M2g9fXrze55vcJty .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M2g9fXrze55vcJty .marker.cross{stroke:#333333;}#mermaid-svg-M2g9fXrze55vcJty svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M2g9fXrze55vcJty p{margin:0;}#mermaid-svg-M2g9fXrze55vcJty .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-M2g9fXrze55vcJty text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-M2g9fXrze55vcJty .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-M2g9fXrze55vcJty .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-M2g9fXrze55vcJty .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-M2g9fXrze55vcJty .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-M2g9fXrze55vcJty #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-M2g9fXrze55vcJty .sequenceNumber{fill:white;}#mermaid-svg-M2g9fXrze55vcJty #sequencenumber{fill:#333;}#mermaid-svg-M2g9fXrze55vcJty #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-M2g9fXrze55vcJty .messageText{fill:#333;stroke:none;}#mermaid-svg-M2g9fXrze55vcJty .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-M2g9fXrze55vcJty .labelText,#mermaid-svg-M2g9fXrze55vcJty .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-M2g9fXrze55vcJty .loopText,#mermaid-svg-M2g9fXrze55vcJty .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-M2g9fXrze55vcJty .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-M2g9fXrze55vcJty .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-M2g9fXrze55vcJty .noteText,#mermaid-svg-M2g9fXrze55vcJty .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-M2g9fXrze55vcJty .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-M2g9fXrze55vcJty .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-M2g9fXrze55vcJty .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-M2g9fXrze55vcJty .actorPopupMenu{position:absolute;}#mermaid-svg-M2g9fXrze55vcJty .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-M2g9fXrze55vcJty .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-M2g9fXrze55vcJty .actor-man circle,#mermaid-svg-M2g9fXrze55vcJty line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-M2g9fXrze55vcJty :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入"帮我制定一周学习计划..."① 扫描 skills / agents / instructions② 只回 frontmatter(name + description)③ 第 1 次请求(system + user + tools)④ tool_call: read_file(SKILL.md)⑤ 读 SKILL.md 正文⑥ 正文(全量 skill 内容)⑦ 第 2 次请求(追加 tool 结果)⑧ tool_call: read_file(plan-template.md)⑨ 第 3 次请求⑩ tool_call: write_file(study.md)⑪ 第 4 次请求⑫ 最终文本⑬ 渲染成 Markdown
阶段 ① ②:扫描磁盘,只读"目录"
程序扫描 .github/ 下的约定路径,只提取 frontmatter,正文一个字节都不读。
这一步的产物是一份"菜单":
- week-plan: USE WHEN: 用户要求制定/生成/调整「一周学习计划」「7天学习安排」「学习排期」「周计划」。按内置模板生成计划并写入 study.md。
- agent-customization: **WORKFLOW SKILL** --- Create, update, review...
为什么只给菜单不给全文? 因为 token 要钱,而且塞进去的信息越多,模型注意力越稀释。这就是 skill 被称为"按需加载"的原因。
阶段 ③:第 1 次请求(完整报文示意)
jsonc
POST https://<模型服务地址>/v1/chat/completions
{
"messages": [
{
"role": "system",
"content": "你是 VS Code 中的 AI 编程助手。\n\n## 可用技能\n当用户请求与某个技能的 description 匹配时,先用 read_file 读取它的 SKILL.md 正文,再严格按正文执行。\n\n- week-plan: USE WHEN: 用户要求制定/生成/调整「一周学习计划」「7天学习安排」「学习排期」「周计划」。按内置模板生成计划并写入 study.md。\n- agent-customization: **WORKFLOW SKILL** --- Create, update, review, fix, or debug VS Code agent customization files...\n"
},
{
"role": "user",
"content": "帮我制定一周学习计划,主题 MySQL 索引原理,每天 60 分钟"
}
],
"tools": [
{
"name": "read_file",
"description": "读取文件内容",
"parameters": { "type": "object", "properties": { "path": { "type": "string" } } }
},
{
"name": "write_file",
"description": "写入文件内容",
"parameters": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } } }
},
{
"name": "grep_search",
"description": "在文件中搜索文本",
"parameters": { "type": "object", "properties": { "query": { "type": "string" } } }
},
{
"name": "run_in_terminal",
"description": "在终端执行命令",
"parameters": { "type": "object", "properties": { "command": { "type": "string" } } }
}
]
}
此刻模型"眼里的世界"
它看到的全部内容,就是上面那串 token。具体说:
| 模型看到的 | 模型看不到的 |
|---|---|
- week-plan: USE WHEN: 用户要求制定... 这行字 |
文件路径、目录结构 |
"帮我制定一周学习计划..." 这行字 |
SKILL.md 的正文 |
| 一份函数表(名字 + 参数格式) | 什么叫"技能"、什么叫"文件夹" |
| 磁盘上还有多少文件 |
重点:模型此刻完全不知道 week-plan 是一份"技能"。它只看到一段带 - 的列表文本,外加一列它可以选择去调用的函数。
阶段 ④:模型返回(第 1 次响应)
模型逐 token 生成,吐出的是一段文本,而这段文本恰好构成了一个合法结构:
jsonc
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_01",
"name": "read_file",
"arguments": { "path": ".github/skills/week-plan/SKILL.md" }
}
]
}
这是整条链路里最颠覆直觉的一点 :
所谓"模型调用了技能",本质是模型生成了一段格式正确的文本,被程序解析成了函数调用。模型自己并没有"打开文件"的能力。
阶段 ⑤ ⑥:程序真的去读文件
程序收到 tool_call → 执行 → 从磁盘读出 SKILL.md 全文。
阶段 ⑦:第 2 次请求 ------ 全量 skill 内容在这里
这是回答"最后发送的全量 skill 是什么"的关键一步。程序把上一步的结果追加到 messages 末尾:
jsonc
POST https://<模型服务地址>/v1/chat/completions
{
"messages": [
{ "role": "system", "content": "你是 VS Code 中的 AI 编程助手。\n\n## 可用技能\n- week-plan: USE WHEN: 用户要求制定/生成/调整「一周学习计划」...\n- agent-customization: ..." },
{ "role": "user", "content": "帮我制定一周学习计划,主题 MySQL 索引原理,每天 60 分钟" },
{ "role": "assistant", "content": null,
"tool_calls": [{ "id": "call_01", "name": "read_file",
"arguments": { "path": ".github/skills/week-plan/SKILL.md" } }] },
{ "role": "tool", "tool_call_id": "call_01",
"content": "---\nname: week-plan\ndescription: 'USE WHEN: 用户要求制定/生成/调整「一周学习计划」「7天学习安排」「学习排期」「周计划」。按内置模板生成计划并写入 study.md。'\n---\n\n# 一周学习计划生成器\n\n## 何时使用\n\n用户提到\"一周学习计划\"\"7 天安排\"\"学习排期\"\"周计划\"时启用本技能。\n\n## 步骤\n\n1. 读取 `assets/plan-template.md`,它是**必须遵守**的输出格式定义。\n2. 收集缺失信息:学习主题、每天可投入时间。用户没给就默认 60 分钟。\n3. 按模板生成 7 天计划。\n4. **写入** `study.md`(覆盖写入)。\n5. 回复里只给\"写入确认 + 3 行摘要\",不要重复整张表。\n\n## 硬性规则\n\n- 第 7 天固定为\"复习 + 自测\"。\n- 每天的验收标准必须可客观判断(例:\"能默写 B+ 树三层结构\")。\n- 禁止\"了解\"\"熟悉\"\"掌握\"这类无法验证的动词。\n\n## 资产\n\n- `assets/plan-template.md` --- 输出格式模板"
}
],
"tools": [ /* 同第 1 次请求,原样重发 */ ]
}
注意三个细节
- 每次请求都要把历史全部重发一遍。 模型是无状态的------它不记得上一次请求。所谓"对话记忆",是程序每次把历史拼进去造出来的假象。
role: "tool"这条消息就是"全量 skill 内容"。 到这一刻,模型才第一次知道这份 skill 要它干什么。tools数组每次都原样重发。 模型"能做什么",取决于这次请求里带了哪些函数签名。
阶段 ⑧:模型返回(第 2 次响应)
现在模型读到了 SKILL.md,正文第 1 条写着"读取 assets/plan-template.md",所以它接着调:
jsonc
{ "role": "assistant", "content": null,
"tool_calls": [{ "id": "call_02", "name": "read_file",
"arguments": { "path": ".github/skills/week-plan/assets/plan-template.md" } }] }
阶段 ⑨ ⑩:第 3 次请求 → 写文件
程序再把模板全文作为 role: "tool" 追加,第 3 次 POST。模型读到模板里的表格列定义,于是调用写文件:
jsonc
{ "role": "assistant", "content": null,
"tool_calls": [{ "id": "call_03", "name": "write_file",
"arguments": {
"path": "study.md",
"content": "# 一周学习计划:MySQL 索引原理\n\n> 每天投入:60 分钟 | 生成时间:2026-09-13\n\n| Day | 主题 | 具体任务 | 验收标准 |\n| --- | ---- | -------- | -------- |\n| 1 | B+ 树结构 | ... | ... |\n..."
} }] }
阶段 ⑪ ⑫:第 4 次请求 → 最终文本
程序追加写入结果,第 4 次 POST。这次模型不再需要工具,直接输出纯文本(按 SKILL.md 第 5 条要求,只给摘要不给全表):
jsonc
{ "role": "assistant",
"content": "已按 `week-plan` 技能写入 `study.md`。\n\n- 覆盖 7 天,第 7 天为复习 + 自测\n- 每天 60 分钟,含具体任务与可量化验收标准\n- 如需调整节奏,告诉我主题或时长即可重生成。" }
阶段 ⑬:渲染
程序把这段文本渲染成 Markdown 显示给你。
全流程代价
| 轮次 | 请求内容要点 | messages 条数 |
|---|---|---|
| 1 | system + user | 2 |
| 2 | + assistant(tool_call) + tool(SKILL.md 全文) | 4 |
| 3 | + assistant + tool(模板全文) | 6 |
| 4 | + assistant + tool(写入结果) | 8 |
你看到的"一个回复",是 4 次独立请求拼出来的。 这就是 Agent 和普通问答的分水岭。
第四部分 · 类比:访问百度
| 浏览器 / 网络世界 | Copilot 对话 |
|---|---|
你在地址栏敲 baidu.com |
你在 Chat 输入框打字 |
| 浏览器查 DNS:域名 → IP | 程序扫 .github/:skill 名 → 要注入的文本 |
| 浏览器组 HTTP 请求 | 程序组 JSON(system + messages + tools) |
| 发包到服务器 | HTTPS POST 到模型服务 |
| 服务器内部处理 | 模型逐 token 推理 |
| 返回 HTML | 流式返回 token |
| 浏览器渲染页面 | 程序渲染成 Markdown |
| 域名 / IP 是网络设施的抽象 | skill / agent 是官方起的名字 |
| ------ | 工具调用循环 ← 关键差异 |
最大差异 :DNS + HTTP 是一次性请求-响应 ;Agent 是多轮循环,一圈一圈把上下文"喂胖"。
第五部分 · 由此推出的结论
5.1 模型全程只见过一维 token 流
文件、目录、skill、agent、MCP------这些概念只存在于程序那一侧。
在模型眼里,它们全都塌缩成了两样东西:
- 文本(提示词里的字)
- 函数签名 (
tools数组里的 JSON Schema)
5.2 三个可直接验证的推论
| 现象 | 真正原因 |
|---|---|
| skill 有时触发、有时不触发 | 是概率采样,不是查表匹配 |
description 写得越准越容易触发 |
描述是菜单上唯一的线索,模型只能靠它判断 |
打开了 SKILL.md 就一定生效 |
文本已在请求体里,绕过了整条发现链路 |
| Agent 里写了"不许跑命令"仍可能跑 | 那是文本(可被说服);摘掉 tools 才是物理拦截 |
5.3 三种约束的可靠度
| 目的 | 手段 | 可靠度 |
|---|---|---|
| "尽量这么做" | 提示词(instructions / skill body) | ⚠️ 概率性 |
| "绝对别做" | 工具白名单(tools: []) |
✅ 确定性 |
| "必须拦下来" | hooks / 审批弹窗 | ✅ 确定性 |
把确定性要求寄托在提示词上,是这套体系里最常见的错误。
第六部分 · 常见误解
| 误解 | 事实 |
|---|---|
"模型认识 SKILL.md 这个文件" |
不认识。它只认识被程序塞进来的文本 |
| "skill 是模型的一种能力" | 不是。是程序读文件 + 拼提示词的组合效果 |
| "对话有记忆" | 没有。每次请求都重发全部历史 |
| "模型打开了文件" | 没有。它生成了一段文本,程序替它打开了文件 |
"skill 是通用术语" |
是 Anthropic 起的名字,VS Code 沿用了 |
| "MCP 和 skill 是一类东西" | 不是。MCP 是开放标准 + 真接口;skill 只是文本 |
"不写 tools 就没有工具" |
反了。省略 = 默认全套 ;tools: [] 才是没有任何工具 |
附录 A · 为什么第一次请求就要带 tools?
A.1 先看你的理解对不对
你的描述:
我给模型提供了 tools,说明这个工具在哪个位置、是干啥的。模型去"点菜",比如它需要读取哪个,并返回一定的格式。返回这个格式之后,真正去读这个 skill 文本的是 Copilot 这个外部程序。外部程序读取完之后,把结果内容再次给到模型。
基本全对,只有一处要修正:tools 里没有"位置"信息。
jsonc
{
"name": "read_file",
"description": "读取文件内容", // ← 干什么
"parameters": {
"type": "object",
"properties": { "path": { "type": "string" } } // ← 参数长什么样
}
}
模型拿到的只有三样:名字、用途、参数格式。
它不知道:
- 这个工具背后是本地磁盘还是网络请求
- 实现代码在哪
- 文件系统里有什么
那个 .github/skills/week-plan/SKILL.md 的路径,是模型自己写出来的字符串 ------它根据上下文(你的话、工作区结构提示、之前的工具结果)猜出来的。程序拿到这个字符串,再去磁盘上找。找不到就报错给它,它再猜一次。
A.2 为什么非得给
因为模型的唯一动作是生成文本,它没有任何执行能力。
那它怎么表达"我想读那个文件"?
方案 A(自然语言):"我想读一下 week-plan 这个技能的正文"
方案 B(结构化): {"name":"read_file","arguments":{"path":"...SKILL.md"}}
方案 A 程序没法可靠解析------"读一下""看看""了解一下"可能是一个意思,中英文还混着。方案 B 一个 JSON 解析就拿到确切意图。
所以 tools 的作用是:告诉模型"请用方案 B 的格式,这些是合法的函数名和参数格式"。
类比:给不会做菜的顾客一份菜单和点菜机。
| 餐厅 | 对应 |
|---|---|
| 顾客(模型) | 不会做菜,只会点单 |
菜单 + 点菜机(tools) |
可点的菜 + 点单格式 |
| 厨房(Copilot 程序) | 真正做事的人 |
端菜上桌(role: "tool" 消息) |
把文件内容送回请求里 |
给菜单不是指望顾客进厨房,是让他能点菜。
A.3 历史上真的没有 tools 这个字段
早期 Agent(ReAct 那套)就是纯文本约定 ,模型直接把下面这段字写出来,程序拿正则去抓:
Thought: 我需要先看这个技能的说明
Action: read_file
Action Input: .github/skills/week-plan/SKILL.md
问题很明显:格式写飘了抓不到、各家约定不统一、模型可能自创不存在的 Action。
所以后来标准化了------把"合法动作清单 + 参数格式"正式声明成 JSON Schema。
所以
tools不是魔法,它是把"正则抓文本"升级成了正式契约。
A.4 tools 的三重身份
| 身份 | 含义 |
|---|---|
| 文档 | "你可以让我做这些事" |
| 语法 | "表达请求必须用这个格式" |
| 白名单 | "列表之外的,别想" |
第三条正是 plan-coach 实验能生效的机制之一:终端工具不在数组里 → 模型没有这个词可用 → 即使它在正文里写"我需要跑个命令",也变不成合法的 tool_call。
A.5 核心问题:模型有记忆吗?
没有。完全没有。
那你担心的第二点就来了:
第二次请求又是一个单独请求,它不记得第一次,这不是有问题了吗?
答案是:程序每次都把完整历史重发一遍。
| 设想 | 效果 |
|---|---|
| 如果模型有记忆 | 只发新增内容就够了 |
| 实际做法(无状态) | 每次把全部往来记录重新发一遍 |
所以第 2 次请求发给模型的实际内容是这样的:
[system] 你是 VS Code 助手......可用技能:- week-plan: USE WHEN: ......
[user] 帮我制定一周学习计划,主题 MySQL 索引原理,每天 60 分钟
[assistant] (tool_call) read_file(".github/skills/week-plan/SKILL.md")
[tool] ← SKILL.md 的全文(本次新增的)
模型看得到自己刚才说了"要读这个文件"。 所以它当然知道为什么会拿到这份内容。
不是它记住了,是聊天记录被整本当复印件重发了一遍。
类比:不是打电话 (对方记得你上次说了啥),而是每次寄一个装着全部往来信件的档案袋------对方每次都是从头读完所有信,才回你这一封。
A.6 为什么这样设计
无状态是为了让模型服务能横向扩展:任意一轮请求可以落到任意一台机器上,不用维持会话。代价就是每次都要重传,token 消耗随轮次线性增长。
A.7 由此解释的几个现象
| 现象 | 真正原因 |
|---|---|
| 长对话越来越慢、越来越贵 | 每次请求都重发全部历史 |
| 聊久了它"忘了"前面说的事 | 超出上下文窗口,被截断或压缩了 |
| 关掉 VS Code 再打开,对话还在 | 历史存在本地磁盘,不存在模型里 |
| 同一句话在不同会话结果不同 | 上下文不同,模型是"就地重算"的 |
| 工具调用的结果它"记得" | 那次结果一直在历史里被持续重发 |
A.8 完整的一圈(点菜闭环)
- 程序把 工具菜单 + 历史 + 你的这句话 打包,POST 出去
- 模型生成文本 → 恰好构成
read_file调用格式 - 程序解析出 tool_call
- 程序真的去磁盘把文件读出来(模型全程没碰过磁盘)
- 程序把「第 1 步的全部内容 + 模型的 tool_call + 文件内容」打包,再 POST 一次
- 模型这次看到了完整前因后果(因为它收到了整本记录)
- 重复 2--6,直到模型输出不带 tool_call 的纯文本
- 程序把这段文本渲染成 Markdown 给你看
你看到的"一个回复",实际是 N 轮独立请求拼出来的。
附录 B · token 成本与上下文增长
B.1 结论先行
因为历史每轮全量重发,所以:
| 维度 | 增长方式 |
|---|---|
| 单次请求的大小 | 线性增长(第 N 轮 = 前 N−1 轮之和 + 固定开销) |
| 整轮对话的总消耗 | 平方级增长 |
| 体感 | 越聊越慢、越聊越贵 |
总 token ≈ (N² / 2) × 每轮平均增量
对话长度翻倍,总消耗翻四倍。
B.2 单次请求:线性
第 N 轮请求实际发出去的内容是:
[固定开销] system + 技能菜单 + 工具 schema ← 每轮都发
[第 1 轮] 你的话
[第 2 轮] 模型的 tool_call + 工具返回结果
[第 3 轮] 模型的 tool_call + 工具返回结果
⋮
[第 N 轮] 你刚打的字 ← 唯一的"新内容"
新内容只有最后一行,前面全是重发。
B.3 算一笔账
用第 2 部分那个"制定一周学习计划"的例子,固定开销(system + 技能菜单 + 工具 schema)约 550 token:
| 轮次 | 本轮新增 | 本次请求实发 | 累计实发 |
|---|---|---|---|
| 1 | 你的话(20) | 570 | 570 |
| 2 | tool_call + SKILL.md 全文(540) |
1110 | 1680 |
| 3 | tool_call + 模板全文(290) | 1400 | 3080 |
| 4 | tool_call(写文件) + 结果(330) | 1730 | 4810 |
实际新增内容合计约 1180 token,实发 4810------放大了 4 倍。
如果这样聊 20 轮,放大倍数会到十几倍。
B.4 为什么这个放大在 Agent 场景尤其严重
普通问答一轮只加几十 token。但 Agent 每轮要追加:
- 一次
tool_call(几十 token) - 一次工具结果(可能是一整个文件,几千 token)
一次 read_file 的结果,可能比前面所有对话加起来还大。 这就是为什么"工具返回内容要截断/摘要"是很多 Agent 产品的核心优化点。
B.5 但真实世界有四层缓冲
| 机制 | 作用 |
|---|---|
| 提示词缓存(prompt caching) | 服务端缓存前缀,重复部分按极低价格甚至免费计费。这是最重要的一层------表面 token 数不等于账单 |
| 上下文压缩 | 接近窗口上限时,程序把旧轮次摘要化或截断 |
| 子代理隔离 | 子代理在自己的上下文里干活,只回一个摘要给主对话。这正是 plan-coach 那个"适合被当作子代理调用"的用意 |
| 输入 / 输出分别计价 | 输出 token 通常贵得多;而重复重发的是便宜的输入 token |
所以:真实成本远低于"表面 token 数",但增长趋势依然存在。
B.6 一个容易被忽略的固定开销
tools 数组每一轮请求都原样重发。
如果你装了 5 个 MCP server,每个暴露 20 个工具,那就是 100 份函数签名,每一轮都发一遍------哪怕这次压根不用它们。
| 操作 | 影响 |
|---|---|
| 装 1 个 MCP server | 每轮多几十~几百 token 固定开销 |
| 装 5 个 MCP server | 每轮多几千 token,每次请求都付 |
| 关掉不用的 server | ✅ 立刻降低每轮固定开销 |
这是实打实的持续成本,拖慢的是每一次请求。 所以"少装几个 MCP server"是有性能收益的,不只是清爽。
B.7 实操结论
| 操作 | 效果 | 为什么 |
|---|---|---|
| 聊完一个话题就新开会话 | ✅✅ | 直接砍掉历史负担,把 N² 拉回 1 |
| 把重活丢给子代理 | ✅✅ | 主对话只留摘要,过程不进主上下文 |
| 关掉不用的 MCP server | ✅ | 降低每轮固定开销 |
| 别长时间开着无关文件 | ✅ | 打开的编辑器每轮都作为附件进请求体 |
| 依靠 skill 的按需加载 | ✅ | 只进 description,正文用时才读 |
| 只看"这次回复用了多少 token" | ❌ | 真正的大头是重复重发的前缀 |
一句话:省 token 的关键不是"少说话",而是"少让历史被反复重发"。