Skip to content

给 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 是一个联合类型:

typescript
type LanguageModelResponsePart2 =
  | LanguageModelResponsePart   // TextPart | ToolCallPart | ToolResultPart
  | LanguageModelDataPart       // 二进制数据(图片等)
  | LanguageModelThinkingPart;  // 思考内容 ← 本文重点

大多数 Provider 只用 LanguageModelTextPart 来推送文本。但如果你的模型后端支持 thinking/reasoning token,就可以利用 LanguageModelThinkingPart 让 VSCode 原生渲染思考过程。

LanguageModelThinkingPart 的类型定义

这个类定义在一个独立的 Proposed API 文件 vscode.proposed.languageModelThinkingPart.d.ts 中:

typescript
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:可选的元数据

使用方式很直接:

typescript
progress.report(new vscode.LanguageModelThinkingPart("模型正在思考的内容..."));

VSCode 收到 ThinkingPart 后会在聊天窗口中渲染一个带 "Thinking..." 标题的可滚动区域,内容实时流入。当 Provider 开始推送 TextPart 时,思考块会自动标记为完成并折叠。

启用 Proposed API

LanguageModelThinkingPart 目前仍是 Proposed API,需要两步启用:

1. package.json 声明

json
{
  "enabledApiProposals": [
    "chatProvider",
    "languageModelThinkingPart"
  ]
}

2. 下载类型定义

bash
npx @vscode/dts dev

这会自动下载 vscode.proposed.languageModelThinkingPart.d.ts 到项目根目录。记得在 .gitignore 中排除:

vscode.proposed.*.ts

3. 用户侧启用

安装 VSIX 后,用户需要在 ~/.vscode/argv.json 中添加:

json
{
  "enable-proposed-api": ["your-publisher.your-extension"]
}

逆向 Cursor CLI 的 Thinking 输出

要接入 thinking,首先要知道模型后端输出了什么。Cursor CLI 的 --output-format stream-json 对 thinking 模型的输出格式是这样的:

json
{"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}}

关键发现:

  1. thinking delta 已经是增量的——每个 type: "thinking", subtype: "delta" 事件的 text 字段直接就是新增文本,不是累积文本
  2. subtype: "completed" 标记思考结束,之后开始正式回答
  3. 非 thinking 模型 不会产生任何 type: "thinking" 事件,所以接入代码对它们完全透明

流式解析器的改造

原始的 stream parser 只处理 type: "assistant"type: "result" 两种事件。改造后需要处理完整的事件类型集合。

回调接口设计

typescript
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;
}

onThinkingDeltaonThinkingDone 是可选的——如果调用方不传这些回调,thinking 事件会被静默忽略,向后兼容。

事件分发逻辑

typescript
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

typescript
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 事件

json
{
  "type": "tool_call",
  "subtype": "started",
  "call_id": "toolu_bdrk_017wYZ...",
  "tool_call": {
    "shellToolCall": {
      "description": "列出当前目录所有文件",
      "args": { "command": "ls" }
    }
  }
}
json
{
  "type": "tool_call",
  "subtype": "completed",
  "tool_call": {
    "shellToolCall": {
      "result": {
        "success": { "exitCode": 0, "executionTime": 594 }
      }
    }
  }
}

工具调用不适合通过 LanguageModelToolCallPart 转发给 Copilot(那会让 Copilot 以为需要自己执行工具),但可以提取描述和执行结果记录到日志:

typescript
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 统计

json
{
  "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 消息,没有的是最终累积消息:

typescript
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-thinkingopus-4.6-thinking)时:

  1. 模型开始思考 → Copilot Chat 显示 "Thinking..." 可折叠块,内容实时流入
  2. 思考完成 → 块自动折叠,标题变为类似 "Thought for 3s"
  3. 正式回答流入 → 和普通文本一样渲染

非 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

最后更新于:

Hosted by GitHub Pages