给 Copilot Chat 加上深度思考:LanguageModelThinkingPart 实战接入
VSCode Copilot Chat 从 2025 年下半年开始支持一个低调但实用的功能:Thinking Block。当你选择一个支持 extended thinking 的模型(如 Claude Sonnet/Opus Thinking),模型的思考过程会以一个可折叠的 "Thinking..." 块实时流式显示在聊天窗口中,思考完成后自动折叠,再展示正式回答。
这个功能的底层是 VSCode 的 LanguageModelThinkingPart API。本文记录如何在一个自定义 Language Model Chat Provider 中接入这个能力——从逆向 Cursor CLI 的 stream-json 输出格式开始,到最终在 Copilot Chat 中渲染出原生的思考块。
背景:Provider API 的 progress 机制
Language Model Chat Provider 通过 provideLanguageModelChatResponse 方法处理聊天请求。它的核心参数之一是 progress: Progress<LanguageModelResponsePart2>,Provider 通过 progress.report() 向 Copilot Chat 推送流式内容。
LanguageModelResponsePart2 是一个联合类型:
type LanguageModelResponsePart2 =
| LanguageModelResponsePart // TextPart | ToolCallPart | ToolResultPart
| LanguageModelDataPart // 二进制数据(图片等)
| LanguageModelThinkingPart; // 思考内容 ← 本文重点大多数 Provider 只用 LanguageModelTextPart 来推送文本。但如果你的模型后端支持 thinking/reasoning token,就可以利用 LanguageModelThinkingPart 让 VSCode 原生渲染思考过程。
LanguageModelThinkingPart 的类型定义
这个类定义在一个独立的 Proposed API 文件 vscode.proposed.languageModelThinkingPart.d.ts 中:
export class LanguageModelThinkingPart {
value: string | string[];
id?: string;
metadata?: { readonly [key: string]: any };
constructor(
value: string | string[],
id?: string,
metadata?: { readonly [key: string]: any }
);
}三个字段:
value:思考文本内容,可以是单个字符串或字符串数组id:可选的思考序列标识符,用于跨轮次追踪metadata:可选的元数据
使用方式很直接:
progress.report(new vscode.LanguageModelThinkingPart("模型正在思考的内容..."));VSCode 收到 ThinkingPart 后会在聊天窗口中渲染一个带 "Thinking..." 标题的可滚动区域,内容实时流入。当 Provider 开始推送 TextPart 时,思考块会自动标记为完成并折叠。
启用 Proposed API
LanguageModelThinkingPart 目前仍是 Proposed API,需要两步启用:
1. package.json 声明
{
"enabledApiProposals": [
"chatProvider",
"languageModelThinkingPart"
]
}2. 下载类型定义
npx @vscode/dts dev这会自动下载 vscode.proposed.languageModelThinkingPart.d.ts 到项目根目录。记得在 .gitignore 中排除:
vscode.proposed.*.ts3. 用户侧启用
安装 VSIX 后,用户需要在 ~/.vscode/argv.json 中添加:
{
"enable-proposed-api": ["your-publisher.your-extension"]
}逆向 Cursor CLI 的 Thinking 输出
要接入 thinking,首先要知道模型后端输出了什么。Cursor CLI 的 --output-format stream-json 对 thinking 模型的输出格式是这样的:
{"type":"thinking","subtype":"delta","text":"用户在问一个数学问题...","timestamp_ms":1773353398883}
{"type":"thinking","subtype":"delta","text":"这是一个简单的加法...","timestamp_ms":1773353398907}
{"type":"thinking","subtype":"completed","timestamp_ms":1773353399476}
{"type":"assistant","message":{"content":[{"type":"text","text":"2+2 = 4"}]}}
{"type":"result","subtype":"success","usage":{"inputTokens":9,"outputTokens":82}}关键发现:
- thinking delta 已经是增量的——每个
type: "thinking", subtype: "delta"事件的text字段直接就是新增文本,不是累积文本 subtype: "completed"标记思考结束,之后开始正式回答- 非 thinking 模型 不会产生任何
type: "thinking"事件,所以接入代码对它们完全透明
流式解析器的改造
原始的 stream parser 只处理 type: "assistant" 和 type: "result" 两种事件。改造后需要处理完整的事件类型集合。
回调接口设计
export interface StreamParserCallbacks {
onText: (text: string) => void;
onDone: () => void;
onThinkingDelta?: (text: string) => void;
onThinkingDone?: () => void;
onToolCallStarted?: (info: ToolCallInfo) => void;
onToolCallCompleted?: (result: ToolCallResult) => void;
onUsage?: (stats: UsageStats) => void;
onSessionInit?: (info: SessionInitInfo) => void;
}onThinkingDelta 和 onThinkingDone 是可选的——如果调用方不传这些回调,thinking 事件会被静默忽略,向后兼容。
事件分发逻辑
export function createStreamParser(cb: StreamParserCallbacks) {
return (line: string) => {
const obj = JSON.parse(line);
switch (obj.type) {
case "thinking":
if (obj.subtype === "delta" && obj.text && cb.onThinkingDelta) {
cb.onThinkingDelta(obj.text);
} else if (obj.subtype === "completed" && cb.onThinkingDone) {
cb.onThinkingDone();
}
break;
case "assistant":
// 处理文本 delta(支持 --stream-partial-output 模式)
break;
case "tool_call":
// 记录工具调用信息
break;
case "result":
// 提取 usage 统计并触发 done
break;
}
};
}Provider 端的接入
Provider 这边的改动很简单——在 createStreamParser 的回调中把 thinking delta 转发为 LanguageModelThinkingPart:
const replyThinking = (text: string) => {
try {
progress.report(new vscode.LanguageModelThinkingPart(text));
} catch { /* disposed or unsupported */ }
};
const parseLine = createStreamParser({
onText: (text) => {
progress.report(new vscode.LanguageModelTextPart(text));
},
onThinkingDelta: (text) => {
replyThinking(text);
},
onThinkingDone: () => {
// VSCode 会自动在收到第一个 TextPart 时折叠 thinking 块
},
// ...其他回调
});try-catch 是必要的——如果用户取消请求,progress 对象可能已经 disposed;如果 VSCode 版本不支持 ThinkingPart,构造函数可能不存在。两种情况都应该静默降级而不是崩溃。
额外收获:完整的 CLI 事件利用
在逆向 thinking 输出的过程中,我们还发现了其他可以利用的事件类型。CLI 的 stream-json 实际上包含了丰富的结构化信息:
tool_call 事件
{
"type": "tool_call",
"subtype": "started",
"call_id": "toolu_bdrk_017wYZ...",
"tool_call": {
"shellToolCall": {
"description": "列出当前目录所有文件",
"args": { "command": "ls" }
}
}
}{
"type": "tool_call",
"subtype": "completed",
"tool_call": {
"shellToolCall": {
"result": {
"success": { "exitCode": 0, "executionTime": 594 }
}
}
}
}工具调用不适合通过 LanguageModelToolCallPart 转发给 Copilot(那会让 Copilot 以为需要自己执行工具),但可以提取描述和执行结果记录到日志:
onToolCallStarted: (info) => {
log(`Tool started: [${info.toolType}] ${info.description}`);
},
onToolCallCompleted: (result) => {
const status = result.success ? "ok" : "fail";
const time = result.executionTimeMs != null ? ` (${result.executionTimeMs}ms)` : "";
log(`Tool completed: [${result.toolType}] ${status}${time}`);
},result 事件的 usage 统计
{
"type": "result",
"subtype": "success",
"duration_ms": 4431,
"usage": {
"inputTokens": 9,
"outputTokens": 82,
"cacheReadTokens": 14079,
"cacheWriteTokens": 1390
}
}Token 用量和耗时对诊断问题很有价值。记录到 Output Channel 后,用户可以在 "Cursor Bridge" 输出通道中看到完整的请求统计:
Usage: in=9, out=82, cache_r=14079, cache_w=1390, 4.4s--stream-partial-output 优化
CLI 支持 --stream-partial-output 标志。启用后,type: "assistant" 消息变成真正的增量 delta(每条只包含新增文本),而不是默认的累积模式(每条包含从头到当前的完整文本)。
区分两种模式的方法是检查 timestamp_ms 字段——有这个字段的是 delta 消息,没有的是最终累积消息:
case "assistant": {
const text = extractAssistantText(obj);
if (!text) break;
if (obj.timestamp_ms) {
// delta 模式:直接转发
cb.onText(text);
} else {
// 累积模式的最终消息:提取差分
if (text.startsWith(accumulated) && accumulated.length > 0) {
const delta = text.slice(accumulated.length);
if (delta) cb.onText(delta);
}
}
break;
}这让解析器在两种模式下都能正确工作,同时 delta 模式减少了不必要的字符串比较。
效果
改造后,当用户在 Copilot Chat 模型选择器中选择 Cursor 的 thinking 模型(如 sonnet-4.5-thinking、opus-4.6-thinking)时:
- 模型开始思考 → Copilot Chat 显示 "Thinking..." 可折叠块,内容实时流入
- 思考完成 → 块自动折叠,标题变为类似 "Thought for 3s"
- 正式回答流入 → 和普通文本一样渲染
非 thinking 模型完全不受影响——CLI 不输出 thinking 事件,相关回调不会被触发。
Output 面板的 "Cursor Bridge" 通道中能看到完整的请求生命周期:
CLI session: id=bc6fb098..., model=Claude 4.5 Opus (Thinking), mode=default
CLI thinking completed
Tool started: [shell] 列出当前目录所有文件
Tool completed: [shell] ok (594ms) 列出当前目录所有文件
Tool started: [read]
Tool completed: [read] ok
Usage: in=15, out=1074, cache_r=27141, cache_w=4636, 22.5s
CLI stream completed
CLI completed (code 0, hasOutput=true)小结
整个改动的代码量不大(约 200 行差分),但覆盖了 Cursor CLI stream-json 的完整事件集合。核心洞见是:LanguageModelThinkingPart 只是一个简单的文本容器,VSCode 负责全部的 UI 渲染(折叠动画、标题变化、高度控制)。Provider 唯一需要做的就是在正确的时机调用 progress.report(new LanguageModelThinkingPart(text))。
剩下的工作(tool_call 日志、usage 统计、session init)不是 UI 层面的集成,但对调试和运维很有价值。当用户报告 "模型回复很慢" 或 "回答不完整" 时,Output Channel 里的详细日志能帮你快速定位问题。
项目开源在 GitHub。