普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月13日首页

NestJS + OpenAI 实现流式输出

2026年4月12日 18:50

在现代 web 应用中,AI 交互的实时性体验越来越重要。本文将详细介绍如何在 NestJS 中集成 OpenAI,并实现流式输出功能,让用户能够看到 AI 回复的实时过程,而不是等待完整回复。

安装依赖

首先,我们需要安装必要的依赖包:

pnpm i @langchain/openai @langchain/core

依赖版本

{
  "dependencies": {
    "@langchain/core": "^1.1.39",
    "@langchain/openai": "^1.4.4"
  }
}

NestJS 配置

为了安全管理 OpenAI API 密钥等配置信息,我们需要设置 NestJS 的环境配置。这里参考了 NestJS 多环境 YAML 配置方案 的实现方式。

config.yaml 中添加 OpenAI 相关配置:

# config/config.yaml
openai:
  apiKey: 'your-api-key'
  model: 'gpt-3.5-turbo'
  baseURL: 'https://api.openai.com/v1'

服务端实现代码

类型定义

首先,我们定义全局类型别名,方便在整个项目中使用:

// types\global.d.ts

import type { Request, Response } from 'express'

declare global {
  /**
   * Express 请求对象类型别名
   * - 说明:用于描述 HTTP 请求的完整信息,包含请求路径、请求参数、请求头、请求体、Cookie 等核心内容
   * - 用途:通常用于定义 Express 接口的请求参数类型,约束和解析请求数据
   */
  type ExpressRequest = Request

  /**
   * Express 响应对象类型别名
   * - 说明:用于描述 HTTP 响应的配置信息,包含响应状态码、响应头、响应体、重定向等核心功能
   * - 用途:通常用于定义 Express 接口的响应格式,返回指定结构的数据给客户端
   */
  type ExpressResponse = Response
}

AI 服务实现

创建 AI 服务,实现流式调用 OpenAI API 的核心逻辑:

// src\modules\ai\ai.service.ts

import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { ChatOpenAI, ChatOpenAIFields } from '@langchain/openai'

@Injectable()
export class AiService {
  constructor(private readonly configService: ConfigService) {}

  /** 调用 OpenAI 模型(流式) */
  public async streamChat(prompt: string, response: ExpressResponse) {
    try {
      // 1. 构造提示词模板
      const chatPromptTemplate = ChatPromptTemplate.fromMessages([
        ['system', '你是一个专业的前端开发人员'],
        ['human', '{input}'],
      ])

      // 2. 拼接链
      const chain = chatPromptTemplate.pipe(this.llm)

      // 3. 流式调用(关键:传入对象,不是字符串)
      const stream = await chain.stream({ input: prompt })

      // 4. 处理流式响应
      for await (const chunk of stream) {
        const content = chunk.content || ''
        if (!content) continue // 过滤空内容
        response.write(`data: ${JSON.stringify({ content })}\n\n`)
      }

      // 5. 发送完成通知
      response.write(`data: ${JSON.stringify({ status: 'DONE' })}\n\n`)
    } catch (error) {
      // 处理错误
      response.write(`data: ${JSON.stringify({ error: error.message })}\n\n`)
    } finally {
      // 6. 结束响应
      response.end()
    }
  }

  /** 获取 OpenAI 模型实例 */
  private get llm(): ChatOpenAI {
    const options: ChatOpenAIFields = {}
    options.apiKey = this.configService.get('openai.apiKey')
    options.model = this.configService.get('openai.model')
    options.temperature = 0.5
    options.streaming = true // 开启流式输出
    options.configuration = {}
    options.configuration.baseURL = this.configService.get('openai.baseURL')
    return new ChatOpenAI(options)
  }
}

控制器实现

创建控制器,处理客户端的流式聊天请求:

// src\modules\ai\ai.controller.ts

import { AiService } from './ai.service'
import { Body, Controller, Post, Res } from '@nestjs/common'

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  @Post('chat/stream')
  public chatStream(@Body('prompt') prompt: string, @Res({ passthrough: true }) response: ExpressResponse) {
    // 设置 SSE 响应头
    response.setHeader('Content-Type', 'text/event-stream') // 设置响应头为事件流
    response.setHeader('Cache-Control', 'no-cache') // 禁用缓存
    response.setHeader('Connection', 'keep-alive') // 保持连接
    return this.aiService.streamChat(prompt, response)
  }
}

客户端实现代码

在前端,我们使用 Vue 3 的 Composition API 实现流式接收和展示 AI 回复:

<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'

const prompt = ref('说一下岳阳楼的故事')
const content = ref('')
const loading = ref(false)

async function chat() {
  if (!prompt.value) {
    alert('请先输入问题')
    return
  }

  content.value = ''
  loading.value = true

  try {
    const url = `http://localhost:3000/api/ai/chat/stream`
    const data = { prompt: prompt.value }
    const response = await axios.post(url, data, { responseType: 'stream', adapter: 'fetch' })

    const reader = response.data.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const chunk = decoder.decode(value, { stream: true })
      const lines = chunk.split('\n').filter((i: string) => i.startsWith('data: '))
      for (const line of lines) {
        try {
          const data = JSON.parse(line.replace('data: ', ''))
          if (data.status === 'DONE') return
          if (data.error) throw new Error(data.error)
          if (data.content) {
            content.value += data.content
            console.log('🚀 ~ data.content :', data.content)
          }
        } catch (error) {
          console.error('解析数据失败:', error)
        }
      }
    }
  } catch (error) {
    console.error('请求失败:', error)
    content.value = `请求失败: ${error.message}`
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="chat-container">
    <div class="input-area">
      <input
        v-model="prompt"
        type="text"
        placeholder="请输入问题..."
        :disabled="loading"
      />
      <button @click="chat" :disabled="loading">
        {{ loading ? '发送中...' : '发送' }}
      </button>
    </div>
    <div class="response-area" v-if="content">
      <h3>AI 回复:</h3>
      <div class="content">{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.chat-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.input-area {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

button {
  padding: 0 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.response-area {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 15px;
  min-height: 200px;
  white-space: pre-wrap;
}
</style>

实现原理

  1. 服务端实现

    • 使用 text/event-stream 响应头启用 Server-Sent Events (SSE)
    • 通过 LangChain 的 stream 方法获取 OpenAI 的流式响应
    • 逐块处理响应数据并通过 SSE 发送给客户端
    • 发送完成信号标记流式传输结束
  2. 客户端实现

    • 使用 responseType: 'stream' 接收流式响应
    • 使用 getReader() 读取响应流
    • 逐块解码并解析数据
    • 实时更新 UI 展示 AI 回复

注意事项

  1. API 密钥安全:确保 OpenAI API 密钥不会被提交到代码仓库,使用环境配置管理
  2. 错误处理:添加适当的错误处理,确保流式传输过程中的错误能够被捕获和处理
  3. 性能优化:对于较长的回复,可以考虑添加节流处理,避免 UI 更新过于频繁
  4. CORS 配置:如果前端和后端分离部署,需要配置适当的 CORS 策略
昨天以前首页

基于 NestJS + LangChain 的 AI 流式对话实战

2026年4月10日 22:27

前言

在 AI 应用开发中,流式输出能极大提升用户体验——让 AI 的回答像打字机一样逐字呈现,而不是等待漫长的完整生成。本文将带从零搭建一个完整的 AI 对话项目,涵盖同步/流式接口、前端 SSE 对接、限流防护等核心能力。

技术栈

  • 后端: NestJS + LangChain
  • 前端: React + Ant Design + EventSource
  • AI 模型: 通义千问 (qwen-plus),兼容 OpenAI API 格式

项目初始化

搭建项目

创建项目

pnpm install -g @nestjs/cli
nest new hello-nest-langchain

安装依赖

pnpm install @nestjs/config
pnpm install @langchain/core @langchain/openai

生成ai模块

nest g res ai --no-spec

配置环境变变量

MODEL_NAME=qwen-plus
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

全局配置 ConfigModel

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AiModule } from './ai/ai.module';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    AiModule,
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

isGlobal 的意思是将 ConfigModel 注册为全局模块,不需要在每个模块的 imports 中重复导入

main.ts 配置跨域

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors();
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

同步接口

在 AiService 里面创建 LangChain 调用链

import { StringOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { Runnable } from '@langchain/core/runnables';
import { ChatOpenAI } from '@langchain/openai';
import { Injectable } from '@nestjs/common';

@Injectable()
export class AiService {
  private readonly chain: Runnable<{ query: string }, string>;

  constructor() {
    const prompt = PromptTemplate.fromTemplate('请回答以下问题: \n\n{query}');

    const model = new ChatOpenAI({
      temperature: 0.7,
      modelName: 'qwen-plus',
      apiKey: 'xxx',
      configuration: {
        baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
      },
    });

    this.chain = prompt.pipe(model).pipe(new StringOutputParser());
  }

  async runChain(query: string): Promise<string> {
    return await this.chain.invoke({ query });
  }
}

在 AiController 里暴露接口

import { Controller, Get, Query } from '@nestjs/common';
import { AiService } from './ai.service';

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  @Get('chat')
  async chat(@Query('query') query: string) {
    const answer = await this.aiService.runChain(query);
    return { answer };
  }
}

流式接口

在 AiService 里面添加流式方法

async *streamChain(query: string): AsyncGenerator<string> {
  const stream = await this.chain.stream({ query });
  for await (const chunk of stream) {
    yield chunk;
  }
}

这时一个流式返回的异步生成器方法,可以让 Ai 的回答像打字机一样一个字一个字的展示,而不是等全部生成完才一次性返回

这里用到了 js 的生成器语法,也就是方法名那里标注 *,然后通过 yield 不断异步返回内容。

前端页面

pnpm create vite
pnpm i @tanstack/react-query
pnpm i antd

组件核心代码

import { useState, useRef, useEffect } from "react";
import "./App.css";
import "antd/dist/reset.css";
import { Card, Input, Button, Typography, Space, Form } from "antd";

const { Title } = Typography;
const { TextArea } = Input;

function App() {
  const [apiUrl, setApiUrl] = useState("http://localhost:3000");
  const [question, setQuestion] = useState("你是谁?");
  const [responseText, setResponseText] = useState("回复将显示在这里...");
  const esRef = useRef<EventSource | null>(null);
  const [isStreaming, setIsStreaming] = useState(false);
  const responseRef = useRef<HTMLDivElement | null>(null);

  const handleStart = () => {
    setResponseText("");
    const base = apiUrl.replace(//+$/, "");
    const url = `${base}/ai/chat/stream?query=${encodeURIComponent(question)}`;

    if (esRef.current) {
      esRef.current.close();
      esRef.current = null;
    }

    try {
      const es = new EventSource(url);
      esRef.current = es;
      setIsStreaming(true);
      es.onmessage = (ev) => {
        const chunk = ev.data;
        setResponseText((prev) => {
          if (
            !prev ||
            prev === "回复将显示在这里..." ||
            prev.startsWith("(演示)")
          )
            return chunk;
          return prev + chunk;
        });
      };
      es.onerror = () => {
        const ready = es.readyState;
        if (ready === 2) {
          setResponseText((prev) => (prev ? prev + "\n【已结束】" : "已结束"));
        }
        try {
          es.close();
        } catch {}
        esRef.current = null;
        setIsStreaming(false);
      };

      // 可选的自定义事件(后端可能发送 event: done)
      es.addEventListener("done", () => {
        try {
          es.close();
        } catch {}
        esRef.current = null;
        setIsStreaming(false);
        setResponseText((prev) => (prev ? prev + "\n【已完成】" : "已完成"));
      });
    } catch (err) {
      setResponseText(`错误:${String(err)}`);
      setIsStreaming(false);
    }
  };

  const handleStop = () => {
    if (esRef.current) {
      esRef.current.close();
      esRef.current = null;
    }
    setIsStreaming(false);
    setResponseText((prev) => (prev ? prev + "\n【已停止】" : "已停止"));
  };

  // 自动滚动到最底部
  useEffect(() => {
    const el = responseRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, [responseText]);

  return (
    <div className="sse-page">
      <Card className="sse-card" bordered={false}>
        <Title level={2} className="sse-title">
          SSE 流式接口测试
        </Title>

        <Form layout="vertical">
          <Form.Item label="API 地址">
            <Input value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
          </Form.Item>

          <Form.Item label="问题">
            <TextArea
              value={question}
              onChange={(e) => setQuestion(e.target.value)}
              rows={3}
            />
          </Form.Item>

          <Form.Item>
            <Space>
              <Button
                type="primary"
                onClick={handleStart}
                disabled={isStreaming}
              >
                开始流式请求
              </Button>
              <Button danger onClick={handleStop} disabled={!isStreaming}>
                停止
              </Button>
            </Space>
          </Form.Item>

          <Form.Item label="">
            <Card className="response-box" bordered={false}>
              <div className="response-content" ref={responseRef}>
                <div className="response-text">{responseText}</div>
              </div>
            </Card>
          </Form.Item>
        </Form>
      </Card>
    </div>
  );
}

export default App;

实现效果

录屏2026-04-10 15.00.13.gif

一些优化

动态注入

将 ChatOpenAI 实例通过 NestJS 的 DI 容器管理,解耦配置与业务逻辑。

nest 动态注入就是不用 new 依赖对象,只要声明下,运行的时候会自动注入依赖的实例对象

在 AiModel 中使用 useFactory 创建 CHAT_MODEL

通过 @Injectable 声明的 Service,和通过 useFactory 创建的对象,都可以作为 provider 来注入

import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';

@Module({
  controllers: [AiController],
  providers: [
    AiService,
    {
      provide: 'CHAT_MODEL',
      useFactory: (configService: ConfigService) => {
        return new ChatOpenAI({
          modelName: configService.get<string>('MODEL_NAME'),
          apiKey: configService.get<string>('OPENAI_API_KEY'),
          configuration: {
            baseURL: configService.get<string>('OPENAI_BASE_URL'),
          },
        });
      },
      inject: [ConfigService],
    },
  ],
})
export class AiModule {}

在 AiService 中直接使用

import { StringOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { Runnable } from '@langchain/core/runnables';
import { ChatOpenAI } from '@langchain/openai';
import { Inject, Injectable } from '@nestjs/common';

@Injectable()
export class AiService {
  private readonly chain: Runnable<{ query: string }, string>;

  constructor(@Inject('CHAT_MODEL') private model: ChatOpenAI) {
    const prompt = PromptTemplate.fromTemplate('请回答以下问题: \n\n{query}');

    this.chain = prompt.pipe(model).pipe(new StringOutputParser());
  }

  async runChain(query: string): Promise<string> {
    return await this.chain.invoke({ query });
  }

  async *streamChain(query: string): AsyncGenerator<string> {
    const stream = await this.chain.stream({ query });
    for await (const chunk of stream) {
      yield chunk;
    }
  }
}

ip 限流保护

安装限流模块

pnpm i @nestjs/throttler

配置 trust proxy 来获取客户端真实的 IP

trust proxy 是 Express 的一个开关,作用是:当请求经过 Nginx / CDN / 负载均衡时,读取 X-Forwarded-For 请求头里的原始客户端 ip

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import type { Express } from 'express';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const expressApp = app.getHttpAdapter().getInstance() as Express;
  expressApp.set('trust proxy', 1);
  app.enableCors();
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

配置全局限流

AppModel 里面添加每个 ip 每秒内最多请求 30 次,并且对所有的请求生效

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AiModule } from './ai/ai.module';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';

@Module({
  imports: [
    AiModule,
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    ThrottlerModule.forRoot([
      {
        ttl: 60000,
        limit: 30,
      },
    ]),
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

在 AiController 里面对 sse 接口限流为每秒钟 5 次

import { Controller, Get, Query, Sse } from '@nestjs/common';
import { AiService } from './ai.service';
import { from, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Throttle } from '@nestjs/throttler';

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  @Get('chat')
  @Throttle({ default: { ttl: 60000, limit: 20 } })
  async chat(@Query('query') query: string) {
    const answer = await this.aiService.runChain(query);
    return { answer };
  }

  @Sse('/chat/stream')
  @Throttle({ default: { ttl: 60000, limit: 5 } })
  chatStream(@Query('query') query: string): Observable<{ data: string }> {
    return from(this.aiService.streamChain(query)).pipe(
      map((chunk) => ({ data: chunk })),
    );
  }
}

封装 useSseChat hook

将 SSE 逻辑抽离为可复用的自定义 Hook

import { useState, useRef, useEffect, useCallback } from "react";

type Status = "idle" | "connecting" | "streaming" | "done" | "error";

interface UseSseChatOptions {
  /** SSE 接口基地址,末尾不需要带斜杠 */
  baseUrl: string;
}

interface UseSseChatReturn {
  /** 当前累积的响应文本 */
  responseText: string;
  /** 当前连接状态 */
  status: Status;
  /** 是否正在流式接收(streaming / connecting) */
  isStreaming: boolean;
  /** 滚动锚点 ref,绑定到响应内容容器上可实现自动滚动 */
  responseRef: React.RefObject<HTMLDivElement | null>;
  /** 发起一次流式请求 */
  start: (query: string) => void;
  /** 手动停止当前流 */
  stop: () => void;
}

export function useSseChat({ baseUrl }: UseSseChatOptions): UseSseChatReturn {
  const [responseText, setResponseText] = useState("回复将显示在这里...");
  const [status, setStatus] = useState<Status>("idle");
  const esRef = useRef<EventSource | null>(null);
  const responseRef = useRef<HTMLDivElement | null>(null);

  const isStreaming = status === "connecting" || status === "streaming";

  // 响应内容变化时自动滚动到底部
  useEffect(() => {
    const el = responseRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, [responseText]);

  // 组件卸载时关闭连接
  useEffect(() => {
    return () => {
      esRef.current?.close();
    };
  }, []);

  const stop = useCallback(() => {
    esRef.current?.close();
    esRef.current = null;
    setStatus("idle");
    setResponseText((prev) => (prev ? prev + "\n【已停止】" : "已停止"));
  }, []);

  const start = useCallback(
    (query: string) => {
      // 关闭上一次未结束的连接
      esRef.current?.close();
      esRef.current = null;

      setResponseText("");
      setStatus("connecting");

      const base = baseUrl.replace(//+$/, "");
      const url = `${base}/ai/chat/stream?query=${encodeURIComponent(query)}`;

      try {
        const es = new EventSource(url);
        esRef.current = es;

        es.onmessage = (ev) => {
          setStatus("streaming");
          setResponseText((prev) => prev + ev.data);
        };

        es.onerror = () => {
          // readyState === 2 表示连接已关闭(正常结束或异常断开)
          if (es.readyState === EventSource.CLOSED) {
            setResponseText((prev) =>
              prev ? prev + "\n【已结束】" : "已结束",
            );
            setStatus("done");
          } else {
            setStatus("error");
          }
          es.close();
          esRef.current = null;
        };

        // 后端可发送 event: done 来明确标记结束
        es.addEventListener("done", () => {
          es.close();
          esRef.current = null;
          setStatus("done");
          setResponseText((prev) => (prev ? prev + "\n【已完成】" : "已完成"));
        });
      } catch (err) {
        setResponseText(`错误:${String(err)}`);
        setStatus("error");
      }
    },
    [baseUrl],
  );

  return { responseText, status, isStreaming, responseRef, start, stop };
}

使用示例

const { responseText, isStreaming, responseRef, start, stop } = useSseChat({
  baseUrl: apiUrl,
});

小结

使用 invoke 和 stream 实现了同步和流式的接口。

在 service层生成流式内容,在 controller 层创建了一个 sse 接口,返回流式数据。

前端使用 EventSource 来监听流式接口的 message 事件。

最后对 sse 请求限流,对依赖进行解耦,对 sse 请求进行封装解耦。

观察 AIRI 源码:一个 Agent 系统如何处理入口、扩展与执行闭环

作者 奇舞精选
2026年4月8日 14:55

在 AI Agent 快速发展的这两年,很多项目都已经能做到“能聊、能演示、能截图。

但真正决定一个项目能走多远的,往往不是首屏效果,而是工程治理能力:请求怎么被接入、会话怎么被隔离、扩展怎么被约束、出错之后怎么继续稳定运行。

Project AIRI 值得借鉴的地方就在这里。 它不是把模型能力包一层 UI,而是在尝试把输入、推理、执行、反馈组织成一套可持续迭代的运行系统。

一、AIRI 是什么:一个“可运行”的 Agent 系统

一句话讲,AIRI 不是“给 LLM 套一层工具调用”的通用 Agent 平台,而是一个面向数字角色场景的运行系统。
它关注的不只是“任务能不能做完”,还关注“角色能不能持续存在、持续互动、跨端一致地存在”。 普通 Agent 平台通常把重点放在流程编排:输入任务、调用工具、返回结果。
AIRI 在这条链路之外,多做了三层事情:

  • 实时交互层:语音输入、语音输出、角色驱动(如 Live2D / VRM)要协同工作,体验目标不是一次性响应,而是“在场感”。
  • 多形态运行层:Web、桌面、移动端不是各做一套,而是围绕共享能力组织,确保角色能力跨端延续。
  • 长期运行层:会话、状态、能力配置、插件扩展都要可持续管理,项目目标是长期演进,不是短期 demo。 所以 AIRI 的核心不是“模型接得多”,而是“把模型、交互、执行、扩展放进同一套可运行系统里”。
    这也是它和常见 Agent 框架最容易被混淆、但本质上差异最大的地方。

从仓库结构可以看到它的职责分层:apps 承接入口,packages 沉淀复用能力,plugins 负责扩展,services 连接外部渠道。 这种分层背后有一个很现实的目标:
在保持产品迭代速度的同时,把“可复用能力”和“可扩展边界”稳定下来。否则功能一多,项目就会很快进入“每加一个能力都要改全局”的状态。

二、请求生命周期:先治理入口,再进入业务

AIRI 的服务入口不是“收到请求就直接执行业务”,而是先做基础治理:跨域策略、会话中间件、请求体限制、观测链路,然后才分发到聊天、Provider、角色等路由。
这一步的价值在于把稳定性问题前置,而不是留给业务代码兜底。

path:apps/server/src/app.ts

const app new Hono<HonoEnv>()
  .use(
    '/api/*',
    cors({
      origin: origin => getTrustedOrigin(origin),
      credentialstrue,
    }),
  )
  .use(honoLogger())
if (otel) {
  app.use('*'otelMiddleware(otel))
}
return app
  .use('*'sessionMiddleware(auth))
  .use('*'bodyLimit({ maxSize1024 * 1024 }))
  .route('/api/providers'createProviderRoutes(providerService))
  .route('/api/chats'createChatRoutes(chatService))

AIRI 把请求处理拆成了“治理层 + 业务层”,属于典型的系统化服务结构。

三、Provider 管理:不是配置项,而是系统资源(精修版)

AIRI 把模型 Provider 当成“用户可管理的资源”来做,而不是把 API Key 直接散落在前端配置里。 从路由实现可以看到两层约束:先通过 authGuard 作为权限入口;创建时用 CreateProviderConfigSchema 做结构化校验,并把 ownerId 绑定到当前用户;修改时走 patch 路由,先加载目标配置,再用 existing.ownerId !== user.id 校验归属,不属于当前用户的改动会被直接拒绝。

path:apps/server/src/routes/providers.ts

export function createProviderRoutes(providerService: ProviderService) {
  return new Hono<HonoEnv>()
    .use('*', authGuard)
    .post('/'async (c) => {
      const user = c.get('user')!
      const body = await c.req.json()
      const result = safeParse(CreateProviderConfigSchema, body)

      if (!result.success) {
        throw createBadRequestError('Invalid Request''INVALID_REQUEST', result.issues)
      }

      const provider = await providerService.createUserConfig({
        ...result.output,
        ownerId: user.id,
      })

      return c.json(provider, 201)
    })
    .patch('/:id'async (c) => {
      const user = c.get('user')!
      const id = c.req.param('id')
      const body = await c.req.json()
      const result = safeParse(UpdateProviderConfigSchema, body)

      if (!result.success) {
        throw createBadRequestError('Invalid Request''INVALID_REQUEST', result.issues)
      }

      const existing = await providerService.findUserConfigById(id)
      if (!existing) throw createNotFoundError()
      if (existing.ownerId !== user.id) throw createForbiddenError()

      const updated = await providerService.updateUserConfig(id, result.output)
      return c.json(updated)
    })
}

这类实现的工程意义在于:Provider 的修改边界由后端路由用 ownerId 校验强制固定下来,而不是依赖前端或约定维持。

四、插件机制:能扩展,也要可控

AIRI 的插件扩展不是“把能力塞进系统就算完成”,而是把插件当成需要长期协作的模块来管理。
插件宿主用状态机约束生命周期阶段:状态机覆盖这些阶段,再到需要配置、完成配置,最终进入就绪状态;失败会进入统一的失败阶段。
这样系统在任何时刻都能回答一个工程问题:插件现在处于什么阶段、下一步应该做什么、失败该怎么被观测与处理。 同时,宿主在插件调用能力前还会做权限断言:扩展能力可以被接入,但不会默认获得越权操作的能力边界。

path:packages/plugin-sdk/src/plugin-host/core.ts

const pluginLifecycleMachine createMachine({
  id'plugin-lifecycle',
  initial'loading',
  states: {
    loading: { on: { SESSION_LOADED'loaded', SESSION_FAILED'failed' } },
    loaded: { on: { START_AUTHENTICATION'authenticating', SESSION_FAILED'failed', STOP'stopped' } },
    authenticating: { on: { AUTHENTICATED'authenticated', SESSION_FAILED'failed' } },
    authenticated: { on: { ANNOUNCED'announced', SESSION_FAILED'failed' } },
    announced: { on: { START_PREPARING'preparing', CONFIGURATION_NEEDED'configuration-needed', STOP'stopped', SESSION_FAILED'failed' } },
    preparing: { on: { WAITING_DEPENDENCIES'waiting-deps', PREPARED'prepared', SESSION_FAILED'failed' } },
    prepared: { on: { CONFIGURATION_NEEDED'configuration-needed', CONFIGURED'configured', SESSION_FAILED'failed' } },
    configuration-needed: { on: { CONFIGURED'configured', SESSION_FAILED'failed' } },
    configured: { on: { READY'ready', SESSION_FAILED'failed' } },
    ready: { on: { REANNOUNCE'announced', CONFIGURATION_NEEDED'configuration-needed', STOP'stopped', SESSION_FAILED'failed' } },
    failed: { on: { STOP'stopped' } },
  },
})


private assertPermission(session: PluginHostSession, input: { area'apis'|'resources'|'capabilities'|'processors'|'pipelines'; actionstring; keystring; reason?: string }) {
  const allowed = this.permissions.isAllowed(this.getPermissionScopeKey(session), input.area, input.action, input.key)
  if (allowed) return
  const error new PermissionDeniedError({ area: input.area, action: input.action, key: input.key })
  session.channels.host.emit(errorPermission, { identity: session.identity, error: { area: input.area, action: input.action, key: input.key, recoverabletrue } })
  throw error
}

扩展增长可以持续,但增长要留在契约和权限边界内。

五、执行闭环:不止“会回复”,还要“会把事做完”

AIRI 在渠道服务(如 Telegram)里的实现,已经体现了典型 agent loop: 先根据上下文推断动作,再执行动作,必要时进入下一轮循环。

path:services/telegram-bot/src/bots/telegram/index.ts

const action = await imagineAnAction(
  ctx.bot.botInfo.id.toString(),
  currentController,
  chatCtx?.messages || [],
  chatCtx?.actions || [],
  { unreadMessages: ctx.unreadMessages, incomingMessages: [incomingMessage] },
)
return await dispatchAction(ctx, action, currentController, chatCtx)

while (typeof result === 'function') {
  result = await result()
}

六、如何理解这个仓库:一条更顺的阅读路径

如果要快速看懂 AIRI 的系统设计,可以按这个顺序:

apps/server:先看请求如何进入系统、入口如何治理 apps/:再看多端入口如何复用核心能力 packages/:看 Provider、Plugin、UI/runtime 的边界抽象 plugins 与 services:看扩展和外部渠道如何接入主链路 按这条路径阅读,会先建立系统主干,再进入扩展细节,不容易陷入局部代码。

结语

当一个 Agent 项目同时具备可治理的入口、可约束的扩展、可追踪的执行闭环,它才真正从“可演示”走向“可运行”,这也是 AIRI 最值得参考的地方,它把 AI 能力从功能展示,推进到了系统工程。

参考链接

Project AIRI:github.com/moeru-ai/ai…

第 18 课:前端框架 — React / Next.js / Vue / Nuxt

作者 王小酱
2026年4月8日 23:15

所属阶段:第四阶段「语言与框架」(第 17-22 课) 前置条件:第 17 课(后端语言) 本课收获:为你的前端框架配置 ECC,掌握 E2E 测试完整方案


一、本课概述

上节课我们学习了后端语言的 Skill 体系。前端领域有自己的独特挑战 — 组件化架构、状态管理、服务端渲染、水合安全、视觉回归测试。ECC 为这些挑战提供了专门的 Skill。

本课回答三个问题:

  1. ECC 有哪些前端 Skill? — 完整清单和适用场景
  2. E2E 测试如何配置? — Playwright + POM 模式深入
  3. 前后端 Skill 如何协作? — 从 UI 到数据库的完整覆盖

二、前端 Skill 全景

2.1 完整 Skill 清单

Skill 定位 核心内容
frontend-patterns 通用前端模式 React/Next.js 组件设计、状态管理、性能优化
frontend-design UI 设计质量 高质量 UI 设计原则、组件库选择、无障碍访问
e2e-testing 端到端测试 Playwright 配置、POM 模式、CI 集成
nuxt4-patterns Nuxt 4 框架 SSR 模式、水合安全、自动导入
nextjs-turbopack Next.js 16+ 增量打包、Turbopack 配置、App Router
frontend-slides 演示文稿 幻灯片组件、动画、演示模式
browser-qa 浏览器质量 自动化视觉测试、截图对比、跨浏览器兼容

2.2 Skill 关系图

┌──────────────────────────────────────────────┐
│              frontend-patterns               │
│    (React/Next.js 通用:组件、状态、性能)    │
├───────────────┬──────────────────────────────┤
│               │                              │
│    ┌──────────▼──────────┐   ┌──────────────▼──────────┐
│    │  nextjs-turbopack   │   │    nuxt4-patterns       │
│    │  Next.js 16+ 专用   │   │    Nuxt 4 专用          │
│    └──────────┬──────────┘   └──────────────┬──────────┘
│               │                              │
├───────────────┴──────────────────────────────┤
│              frontend-design                 │
│       (UI 质量:设计系统、无障碍)            │
├──────────────────────────────────────────────┤
│     e2e-testing          browser-qa          │
│   (功能测试)          (视觉测试)          │
└──────────────────────────────────────────────┘

三、frontend-patterns 核心内容

3.1 React 组件设计原则

frontend-patterns Skill 强调以下 React 最佳实践:

组件分类

类型 职责 状态 示例
展示组件 渲染 UI 无/最少 Button, Card, Avatar
容器组件 数据获取和逻辑 UserProfile, OrderList
布局组件 页面结构 PageLayout, Sidebar
高阶组件 逻辑复用 取决于 withAuth, withTheme

不可变状态管理(呼应 SOUL.md 的 Immutability 原则):

// WRONG — 直接修改状态
const handleAdd = (item) => {
  state.items.push(item);       // 变异!
  setState(state);              // React 不会重新渲染
};

// CORRECT — 创建新数组
const handleAdd = (item) => {
  setState(prev => ({
    ...prev,
    items: [...prev.items, item]  // 新数组,触发重新渲染
  }));
};

3.2 状态管理策略

frontend-patterns 推荐分层的状态管理策略:

局部状态    → useState / useReducer     (组件内部)
共享状态    → Context / Zustand / Jotai  (跨组件)
服务器状态  → TanStack Query / SWR       (API 数据)
URL 状态    → 路由参数 / searchParams     (可分享)

原则:状态应该放在最靠近使用它的地方。不要把所有状态都丢进全局 store。

3.3 性能优化

问题 解决方案 何时使用
不必要的重新渲染 React.memo + useMemo 大列表、复杂计算
大型 bundle 代码分割 + lazy() 路由级别
首屏加载慢 SSR / SSG 内容型页面
图片加载慢 next/image + 懒加载 图片密集页面
长列表卡顿 虚拟滚动 1000+ 条记录

四、框架专用 Skill

4.1 nextjs-turbopack(Next.js 16+)

Next.js 16 引入了 Turbopack 作为默认打包工具,nextjs-turbopack Skill 覆盖:

  • App Router:Server Components vs Client Components 的边界划分
  • 增量打包:Turbopack 的增量编译策略,开发环境热更新优化
  • Server Actions:表单处理、数据变更的服务端模式
  • 缓存策略fetch 缓存、revalidate、ISR 配置
关键决策:何时使用 Server Component vs Client Component

Server Component(默认):
  ✓ 数据获取
  ✓ 访问后端资源
  ✓ 敏感信息(API key)
  ✗ 事件处理
  ✗ 浏览器 API

Client Component('use client'):
  ✓ 事件处理(onClick 等)
  ✓ useState / useEffect
  ✓ 浏览器 API(localStorage 等)
  ✗ 直接数据库查询

4.2 nuxt4-patterns(Nuxt 4)

Nuxt 4 的 Skill 重点关注 SSR 安全和 Vue 3 的组合式 API:

水合安全(Hydration Safety)是 Nuxt 4 最常见的坑:

服务端渲染 HTML → 客户端 JS 接管 → 对比 DOM 是否一致
                                     ↓
                              不一致 = 水合错误!

常见水合错误原因:

  • 使用 Date.now()Math.random() — 服务端和客户端结果不同
  • 直接访问 windowdocument — 服务端没有这些对象
  • 条件渲染依赖客户端状态 — 服务端和客户端渲染结果不同

nuxt4-patterns 的解决方案:

  • 使用 <ClientOnly> 组件包裹仅客户端内容
  • 使用 useNuxtApp()$client 标志做条件判断
  • 自动导入系统避免手动 import 导致的 tree-shaking 问题

五、E2E 测试深入

5.1 e2e-testing Skill 核心

e2e-testing 是前端 Skill 中最重要的测试 Skill,基于 Playwright 构建完整的 E2E 测试方案。

Playwright 配置模板

// playwright.config.js
const config = {
  testDir: './e2e',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
};

5.2 POM(Page Object Model)模式

POM 是 e2e-testing Skill 强烈推荐的测试组织模式:

e2e/
├── pages/                    # Page Object 定义
│   ├── login.page.js         # 登录页
│   ├── dashboard.page.js     # 仪表盘页
│   └── settings.page.js      # 设置页
├── fixtures/                 # 测试夹具
│   └── auth.fixture.js       # 认证状态
├── specs/                    # 测试用例
│   ├── login.spec.js
│   └── dashboard.spec.js
└── playwright.config.js

Page Object 示例

// e2e/pages/login.page.js
class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = page.locator('[data-testid="email"]');
    this.passwordInput = page.locator('[data-testid="password"]');
    this.submitButton = page.locator('[data-testid="submit"]');
    this.errorMessage = page.locator('[data-testid="error"]');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

测试用例示例

// e2e/specs/login.spec.js
test('should login with valid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password');
  await expect(page).toHaveURL('/dashboard');
});

5.3 截图/视频/Trace 管理

产物 何时生成 存储策略 CI 注意事项
截图 失败时自动 test-results/ 作为 CI artifact 上传
视频 首次重试时 test-results/ 仅保留失败用例的视频
Trace 首次重试时 test-results/ npx playwright show-trace 回放

5.4 Flaky Test 隔离策略

Flaky test(时灵时不灵的测试)是 E2E 测试最大的痛点。e2e-testing Skill 推荐:

  1. 重试机制:CI 环境设置 retries: 2,本地设置 retries: 0
  2. 等待策略:永远不要用 sleep,用 Playwright 的自动等待或 waitFor
  3. 数据隔离:每个测试创建自己的测试数据,不依赖其他测试的状态
  4. 标记隔离:用 test.fixme() 标记已知 flaky 测试,定期修复
// 不要这样写
await page.waitForTimeout(3000);  // 硬等 3 秒

// 应该这样写
await page.waitForSelector('[data-testid="loaded"]');  // 等待确定信号

六、browser-qa Skill

6.1 自动化视觉测试

browser-qa Skill 补充了 e2e-testing 的功能测试,聚焦视觉回归

功能测试(e2e-testing):按钮点击后跳转正确吗?
视觉测试(browser-qa):按钮的颜色、位置、大小变了吗?

视觉测试的核心流程:

基准截图(baseline)
     ↓
当前截图(current)
     ↓
像素级对比
     ↓
差异超过阈值? → 标记为回归

6.2 跨浏览器兼容性

browser-qa 结合 Playwright 的多浏览器支持,确保在 Chrome、Firefox、Safari 上的一致表现。


七、前后端 Skill 协作

7.1 全栈项目的 Skill 组合

一个典型的全栈项目需要三层 Skill 覆盖:

┌─────────────────────────────────────────────┐
│  前端层                                      │
│  frontend-patterns + e2e-testing             │
│  (React 组件 + Playwright 测试)            │
├─────────────────────────────────────────────┤
│  API 层                                      │
│  api-design + backend-patterns               │
│  (RESTful 设计 + 分层架构)                  │
├─────────────────────────────────────────────┤
│  数据层                                      │
│  postgres-patterns + database-migrations     │
│  (查询优化 + 零停机迁移)                    │
└─────────────────────────────────────────────┘

7.2 常见全栈 Skill 组合

技术栈 前端 Skill API Skill 数据 Skill
React + Node frontend-patterns, e2e-testing api-design, nestjs-patterns postgres-patterns
Next.js 全栈 frontend-patterns, nextjs-turbopack api-design, backend-patterns postgres-patterns, database-migrations
Vue + Django nuxt4-patterns, e2e-testing api-design, django-patterns postgres-patterns
React + Spring frontend-patterns, e2e-testing api-design, springboot-patterns jpa-patterns

7.3 协作场景示例

场景:用户列表页面需要分页功能。

前端 Skill 指导:
  frontend-patterns → 分页组件设计、URL 状态同步
  e2e-testing → 分页翻页的 E2E 测试

API Skill 指导:
  api-design → 分页参数格式(page/limit)、响应信封格式
  backend-patterns → Service 层分页逻辑

数据 Skill 指导:
  postgres-patterns → OFFSET/LIMIT vs 游标分页性能
  database-migrations → 添加分页所需索引

八、本课练习

练习 1:浏览前端 Skill(10 分钟)

# 查看前端 Skill 列表
ls skills/frontend-patterns/
ls skills/e2e-testing/

回答问题:

  • frontend-patterns 涵盖了哪些子主题?
  • e2e-testing 推荐的测试组织方式是什么?

练习 2:设计 POM 结构(15 分钟)

为一个包含以下页面的应用设计 Page Object 结构:

  • 登录页
  • 注册页
  • 商品列表页
  • 商品详情页
  • 购物车页
  • 结算页

写出 e2e/pages/ 目录下的文件列表,以及每个 Page Object 的关键定位器(locator)。

练习 3:规划全栈 Skill 组合(20 分钟)

这是本课最重要的练习。

为一个前后端分离项目规划 Skill 组合:

  • 前端:Next.js App Router
  • 后端:你的主力语言(Python/Go/Java 等)
  • 数据库:PostgreSQL

列出每层需要的 Skill,并说明每个 Skill 在项目中解决什么具体问题。

练习 4(选做):思考题

Nuxt 4 的水合安全问题和 Next.js 的 Server/Client Component 边界问题,本质上是同一类问题吗?它们的共同根源是什么?


九、本课小结

你应该记住的 内容
前端 Skill 7 个 Skill 覆盖组件设计、E2E、视觉测试、SSR 框架
E2E 核心 Playwright + POM 模式 + 失败时截图/视频/trace
Flaky Test 不要 sleep,用自动等待;数据隔离;CI 重试
前后端协作 三层 Skill 覆盖:前端层 + API 层 + 数据层
全栈组合 按技术栈选择 Skill 组合,确保每层都有覆盖

十、下节预告

第 19 课:移动端开发 — Swift / SwiftUI / Dart / Flutter

下节课我们将进入移动端领域。Swift 生态的 Skill 非常深入 — 从 Swift 6.2 的并发模型到设备端 LLM,再到 Flutter 的跨平台架构。你将了解移动端开发与 Web 开发在 Skill 配置上的关键差异。

预习建议:提前浏览 skills/swiftui-patternsskills/dart-flutter-patterns 目录。

第 17 课:后端语言 — Python / Go / Rust / Java

作者 王小酱
2026年4月8日 23:14

所属阶段:第四阶段「语言与框架」(第 17-22 课) 前置条件:第 9 课(Skill 编写)、第 13 课(TDD 流程) 本课收获:为你的主力后端语言配置完整的 ECC 规则和 Skill


一、本课概述

ECC 不只是一个通用的 AI 编程增强框架 — 它对每种主流后端语言都提供了量身定制的 Skill 和 Agent。这些 Skill 涵盖了编码规范、测试策略、代码审查和并发模式,让 Claude Code 在你的主力语言中表现得像一个有十年经验的高级工程师。

本课回答三个问题:

  1. ECC 支持哪些后端语言? — 完整的 Skill 对照表
  2. 语言 Skill 有什么共性结构? — 理解通用骨架,举一反三
  3. 如何为你的主力语言配置 ECC? — 从 Skill 选择到 TDD 实战

二、后端语言 Skill 对照表

ECC 为 8 种后端语言提供了专用 Skill 体系。每种语言至少覆盖三个维度:编码规范测试策略代码审查

2.1 完整对照表

语言 编码规范 Skill 测试 Skill 审查 Agent 附加 Skill
Python python-patterns python-testing python-reviewer django-patterns, django-tdd, django-security
Go golang-patterns golang-testing go-reviewer
Rust rust-patterns rust-testing rust-reviewer
Java java-coding-standards springboot-tdd java-reviewer springboot-patterns, springboot-security, jpa-patterns
Kotlin kotlin-patterns kotlin-testing kotlin-reviewer kotlin-coroutines-flows, kotlin-exposed-patterns, kotlin-ktor-patterns
C++ cpp-coding-standards cpp-testing cpp-reviewer
C# dotnet-patterns csharp-testing csharp-reviewer
Perl perl-patterns perl-testing perl-security

2.2 语言生态丰富度

从附加 Skill 的数量可以看出 ECC 对各语言的支持深度:

Kotlin  ████████████  6 个 Skill(含框架级 Ktor、Exposed、协程)
Java    ████████████  5 个 Skill(含 Spring Boot 全套)
Python  ████████████  5 个 Skill(含 Django 全套)
Go      ████          3 个 Skill
Rust    ████          3 个 Skill
C++     ████          3 个 Skill
C#      ████          3 个 Skill
Perl    ████          3 个 Skill

提示:Skill 数量不等于支持质量。Go 虽然只有 3 个 Skill,但 golang-patterns 覆盖了并发、错误处理、接口设计等核心主题,信息密度很高。


三、语言 Skill 共性结构

无论哪种语言,ECC 的编码规范 Skill 都遵循相同的骨架结构。理解这个骨架,你就能快速上手任何语言的 Skill。

3.1 四大共性维度

每个语言 Skill 都会覆盖以下四个维度:

维度 含义 示例(Go) 示例(Python)
命名惯用法 变量、函数、类型的命名规范 camelCase 未导出, PascalCase 导出 snake_case 函数, PascalCase
错误处理 语言惯用的错误处理模式 返回 (value, error) 二元组 try/except + 自定义异常层级
并发模式 语言原生的并发/异步机制 goroutine + channel asyncio + async/await
测试框架 推荐的测试工具和组织方式 testing 标准库 + table-driven pytest + fixture + parametrize

3.2 命名惯用法对比

┌─────────┬──────────────┬──────────────┬──────────────┐
│  语言    │  变量/函数    │  类型/类      │  常量         │
├─────────┼──────────────┼──────────────┼──────────────┤
│ Python  │ snake_case   │ PascalCase   │ UPPER_SNAKE  │
│ Go      │ camelCase    │ PascalCase   │ PascalCase   │
│ Rust    │ snake_case   │ PascalCase   │ UPPER_SNAKE  │
│ Java    │ camelCase    │ PascalCase   │ UPPER_SNAKE  │
│ Kotlin  │ camelCase    │ PascalCase   │ UPPER_SNAKE  │
│ C++     │ snake_case   │ PascalCase   │ kPascalCase  │
│ C#      │ camelCase    │ PascalCase   │ PascalCase   │
│ Perl    │ snake_case   │ PascalCase   │ UPPER_SNAKE  │
└─────────┴──────────────┴──────────────┴──────────────┘

3.3 错误处理模式对比

各语言的错误处理哲学差异很大,但 ECC 的 Skill 都会强调一个共同点:永远不要吞掉错误

# Python — 异常层级
class AppError(Exception): pass
class NotFoundError(AppError): pass
class ValidationError(AppError): pass

try:
    user = find_user(user_id)
except NotFoundError:
    return {"error": "User not found"}, 404
// Go — 显式错误返回
user, err := findUser(userID)
if err != nil {
    if errors.Is(err, ErrNotFound) {
        return nil, fmt.Errorf("user %s not found: %w", userID, err)
    }
    return nil, err
}
// Rust — Result 类型 + ? 操作符
fn find_user(id: &str) -> Result<User, AppError> {
    let user = db.query(id).map_err(|e| AppError::NotFound(e))?;
    Ok(user)
}

3.4 并发模式对比

语言 并发原语 通信方式 ECC Skill 关注点
Python asyncio / threading Queue / Event 避免 GIL 陷阱,异步 IO 优先
Go goroutine channel "Don't communicate by sharing memory"
Rust tokio / std::thread mpsc channel 所有权系统保证线程安全
Java Thread / ExecutorService BlockingQueue 线程池配置,避免死锁
Kotlin coroutine Flow / Channel 结构化并发,kotlin-coroutines-flows

四、语言特定 Agent 与命令

4.1 代码审查 Agent

每种语言都有专用的 Reviewer Agent,它们不只是通用的代码审查,而是深入理解语言惯用法:

Agent 审查重点
python-reviewer PEP 8 合规、类型标注完整性、Django ORM N+1
go-reviewer 接口最小化、error wrapping、goroutine 泄漏
rust-reviewer 所有权和生命周期、unsafe 使用审计、clippy 警告
java-reviewer Spring Bean 作用域、JPA 懒加载、空指针防护
kotlin-reviewer 空安全使用、协程作用域泄漏、data class 不可变性
cpp-reviewer 内存管理、智能指针使用、RAII 模式
csharp-reviewer async/await 死锁、IDisposable 实现、LINQ 性能

4.2 构建错误解决 Agent

当编译或构建失败时,ECC 提供了语言专用的 Build Resolver:

通用:build-error-resolver
Go:go-build-resolver
Java:java-build-resolver
Kotlin:kotlin-build-resolver
Rust:rust-build-resolver
C++:cpp-build-resolver
Dart:dart-build-resolver
Python:pytorch-build-resolver(PyTorch 专用)

这些 Agent 理解各语言构建工具的错误格式(go buildcargo buildgradle build),能快速定位并修复问题。

4.3 语言特定 TDD 命令

ECC 的 /tdd 命令是通用的 TDD 入口,但配合语言 Skill 使用效果更好:

# 通用 TDD 流程
/tdd

# 配合语言审查命令(在 TDD 完成后执行)
# Python 项目
/code-review   # 自动匹配 python-reviewer

# Go 项目
/code-review   # 自动匹配 go-reviewer

# Java/Spring Boot 项目
/code-review   # 自动匹配 java-reviewer

TDD 流程中,语言 Skill 的 测试框架 部分会被自动注入:

语言 测试 Skill 注入的内容
Python pytest fixture 模式、parametrize、mock.patch
Go table-driven tests、testify 断言、httptest
Rust #[cfg(test)] 模块、proptest 属性测试
Java JUnit 5 + Mockito、Spring Boot @WebMvcTest
Kotlin kotest + MockK、协程测试 runTest

五、框架级 Skill 深入

5.1 Python + Django 生态

Django 在 ECC 中有完整的 Skill 链:

django-patterns     → ORM 查询优化、视图模式、中间件
django-tdd          → Django TestCase、Factory Boy、API 测试
django-security     → CSRF、SQL 注入、XSS 防护
django-verification → 部署前检查清单

5.2 Java + Spring Boot 生态

springboot-patterns      → 分层架构、依赖注入、配置管理
springboot-tdd           → @SpringBootTest@WebMvcTest、TestContainers
springboot-security      → Spring Security 配置、JWT、OAuth2
springboot-verification  → 部署前检查清单

5.3 Kotlin 生态(最丰富)

Kotlin 是 ECC 中 Skill 覆盖最广的后端语言:

kotlin-patterns          → 空安全、密封类、扩展函数
kotlin-testing           → kotest、MockK、协程测试
kotlin-coroutines-flows  → 结构化并发、Flow 操作符、异常处理
kotlin-exposed-patterns  → Exposed ORM DSL、事务管理、HikariCP
kotlin-ktor-patterns     → 路由 DSL、内容协商、插件系统

六、实战配置指南

6.1 为你的语言选择 Skill 组合

根据项目类型选择合适的 Skill 组合:

Python Web API 项目:
  python-patterns + python-testing + django-patterns + django-tdd + api-design

Go 微服务项目:
  golang-patterns + golang-testing + api-design + postgres-patterns

Rust CLI 工具项目:
  rust-patterns + rust-testing

Java Spring Boot 项目:
  java-coding-standards + springboot-patterns + springboot-tdd + jpa-patterns

Kotlin Ktor 项目:
  kotlin-patterns + kotlin-testing + kotlin-ktor-patterns + kotlin-exposed-patterns

6.2 Rules 层配置

除了 Skill,还需要安装对应语言的 Rules:

# ECC rules 目录结构
rules/
├── common/          # 通用规则(必装)
├── typescript/      # TypeScript 规则
├── python/          # Python 规则
├── golang/          # Go 规则
└── swift/           # Swift 规则

安装命令:

# 安装通用 + 语言规则
./install.sh python
./install.sh golang

# 或手动复制
cp -r rules/common ~/.claude/rules/common
cp -r rules/python ~/.claude/rules/python

七、本课练习

练习 1:查看你的语言 Skill(10 分钟)

选择你的主力后端语言,阅读对应的编码规范 Skill:

# 以 Python 为例
cat skills/python-patterns/README.md

# 以 Go 为例
cat skills/golang-patterns/README.md

回答问题:

  • 该 Skill 覆盖了哪些主题?
  • 错误处理部分推荐了什么模式?
  • 有没有你不认同的惯用法?

练习 2:执行 TDD + 语言审查(20 分钟)

用你的主力语言完成以下流程:

  1. /tdd 命令为一个简单函数(如字符串验证工具)执行完整的 RED-GREEN-IMPROVE 循环
  2. 完成后用 /code-review 触发语言专用审查
  3. 记录 Reviewer Agent 给出的反馈

练习 3:对比两种语言的错误处理(15 分钟)

选择两种你熟悉的语言,分别阅读它们的编码规范 Skill 中关于错误处理的部分。写一段 200 字以内的对比总结。

练习 4(选做):思考题

如果你要为一种 ECC 尚未支持的语言(如 Zig、Elixir)编写 Skill,你会先写哪三个部分?为什么?


八、本课小结

你应该记住的 内容
支持范围 8 种后端语言,每种至少 3 个 Skill
共性结构 命名惯用法、错误处理、并发模式、测试框架
语言 Agent 每种语言有专用 Reviewer + Build Resolver
框架 Skill Django/Spring Boot/Ktor 等有完整的 Skill 链
配置方式 Skill 选择 + Rules 安装,按项目类型组合

九、下节预告

第 18 课:前端框架 — React / Next.js / Vue / Nuxt

下节课我们将进入前端领域,学习 ECC 如何支持现代前端框架。你将了解 E2E 测试的完整方案(Playwright + POM 模式),以及前后端 Skill 如何协作形成完整的应用覆盖。

预习建议:提前浏览 skills/frontend-patternsskills/e2e-testing 目录,感受前端 Skill 的内容组织方式。

第 16 课:多代理编排 — 并行、视角与隔离

作者 王小酱
2026年4月8日 23:14

所属阶段:第三阶段「工作流实战」(第 12-20 课) 前置条件:第 15 课(会话管理) 本课收获:能设计多 Agent 协作方案,理解并行与顺序的判断标准


一、本课概述

前面 15 课都在讲"一个 Agent 做一件事"。但现实中的复杂任务,往往需要多个 Agent 协作才能高效完成。

想象一个场景:你提交了一段代码,需要同时做安全审查、性能审查和类型检查。如果顺序执行,要等三倍时间。如果并行执行,三个 Agent 同时工作,只需等最慢的那个。

本课回答三个问题:

  1. 什么时候并行,什么时候顺序? — 判断标准和决策树
  2. 怎么编排多 Agent? — 四种编排模式
  3. 如何避免 Agent 之间互相干扰? — 上下文隔离和 Git Worktree

学完本课,你将能设计合理的多 Agent 协作方案,避免常见的编排陷阱。


二、并行 vs 顺序 — 判断标准

2.1 核心规则

无依赖 → 并行
有依赖 → 顺序

这条规则看似简单,但判断"是否有依赖"需要仔细分析。

2.2 判断依赖的三个问题

对于任意两个任务 A 和 B,问自己三个问题:

  1. B 是否需要 A 的输出? — 如果是,顺序执行
  2. A 和 B 是否修改同一个文件? — 如果是,顺序执行
  3. B 的决策是否取决于 A 的结果? — 如果是,顺序执行

三个问题全部回答"否",才能并行。

2.3 实例分析

场景一:代码提交后的审查(可并行)

安全审查 ──┐
            │
性能审查 ──┼── 互不依赖,可并行
            │
类型检查 ──┘

理由:
✓ 安全审查不需要性能审查的输出
✓ 三者都只读不写(不修改文件)
✓ 三者的判断互相独立

场景二:功能开发流程(必须顺序)

规划 → 实现 → 审查

理由:
✗ 实现需要规划的输出(实施步骤)
✗ 审查需要实现的输出(代码变更)
✗ 后续步骤的决策依赖前一步的结果

场景三:多语言构建检查(可并行)

Go build ────┐
              │
Rust build ──┼── 不同语言的构建互不影响
              │
Node build ──┘

场景四:前后端开发(部分并行)

API 设计(顺序 — 先定接口)
    │
    ├── 前端开发 ──┐
    │              ├── 并行(依赖同一接口,但修改不同文件)
    └── 后端开发 ──┘
            │
        集成测试(顺序 — 需要前后端都完成)

2.4 决策流程图

┌─────────────────────────┐
   N 个任务需要完成      
└────────┬────────────────┘
         
         
┌─────────────────────────┐
  任意两个任务之间         
  是否存在数据依赖?       
└────┬──────────┬─────────┘
               
    Yes         No
               
               
┌──────────┐  ┌──────────────┐
 顺序执行    │是否修改同一文件?│
└──────────┘  └──┬───────┬───┘
                        
                Yes      No
                        
                        
           ┌──────────┐ ┌──────────┐
            顺序执行    并行执行  
           └──────────┘ └──────────┘

三、四种编排模式

ECC 提供了从简单到复杂的四种编排模式:

3.1 模式一:单 Agent

用户 → Agent → 输出

适用:简单任务,一个专家就够
示例:/code-review → code-reviewer Agent → 审查报告

这是最基础的模式,前面所有课程都在用。

3.2 模式二:并行 Agent(/multi-execute)

用户 → 编排层 ─┬→ Agent A ─┐
               ├→ Agent B ─┤→ 汇总 → 输出
               └→ Agent C ─┘

适用:多个独立任务可以同时做
命令:/multi-execute

实际调用方式

# 并行执行三个审查任务
Launch 3 agents in parallel:
1. Agent 1: Security analysis of auth module
2. Agent 2: Performance review of cache system
3. Agent 3: Type checking of utilities

关键约束:并行的 Agent 必须互不依赖,否则结果不可靠。

3.3 模式三:多模型协作(/multi-plan)

用户 → 主 Agent (Claude) ─┬→ Codex 分析 ─┐
                           │              ├→ 融合 → 计划
                           └→ Gemini 分析 ┘

适用:需要多个 AI 模型的不同视角
命令:/multi-plan

/multi-plan 的核心协议:

  • 语言协议:与工具/模型交互用英文,与用户交流用用户的语言
  • 代码主权:外部模型(Codex/Gemini)没有文件写权限,所有修改由 Claude 执行
  • 脏原型重构:外部模型的输出视为"脏原型",必须重构为生产级代码
  • 止损机制:当前阶段输出验证通过前不进入下一阶段

3.4 模式四:级联执行(/orchestrate)

用户 → 编排层 → Agent A → Agent B → Agent C → 输出
                   │         ▲
                   └─────────┘
                   A 的输出是 B 的输入

适用:复杂工作流,多个阶段有依赖
命令:/orchestrate
技能:dmux-workflows + autonomous-agent-harness

级联执行是最复杂的模式,适用于:

  • 长时间运行的自动化任务
  • 需要治理和调度的循环任务
  • 多阶段有依赖的工作流

3.5 模式对比表

模式 复杂度 Agent 数 依赖关系 典型场景
单 Agent 1 简单任务
并行 Agent 2-4 无依赖 多维度审查
多模型协作 2-3 AI 无依赖 方案比较
级联执行 最高 多个 有依赖 完整工作流

四、多视角分析

4.1 五种角色

ECC 的 agents.md 推荐用 5 种角色审查同一段代码,每种角色关注不同维度:

角色 关注点 典型发现
事实审查员 代码是否正确实现了需求 逻辑错误、边界错误、遗漏需求
高级工程师 代码质量和可维护性 过度复杂、重复代码、命名不当
安全专家 安全漏洞和攻击面 注入风险、密钥泄露、权限绕过
一致性审查员 与项目现有模式的一致性 风格偏离、模式混用、约定违反
冗余检查员 不必要的代码和依赖 死代码、未使用的导入、冗余计算

4.2 为什么需要多视角

单一视角的审查有盲区。每个角色都有自己的"关注偏差":

安全专家可能不关注代码可读性
高级工程师可能忽视安全漏洞
事实审查员可能忽视性能问题

5 种角色的审查加在一起,形成全方位的覆盖。

4.3 实战用法

# 对 auth.js 进行多视角审查
Launch 5 sub-agents with different perspectives:

1. Factual Reviewer:
   Check if auth.js correctly implements OAuth 2.0 PKCE flow
   per the RFC 7636 specification.

2. Senior Engineer:
   Review code quality: function sizes, naming, error handling,
   immutability patterns.

3. Security Expert:
   Analyze for token leakage, timing attacks, CSRF,
   improper token storage.

4. Consistency Reviewer:
   Compare with existing auth patterns in the codebase.
   Check if middleware and error handling match project conventions.

5. Redundancy Checker:
   Find unused imports, dead code paths, duplicate validation logic.

五、子代理上下文丢失问题

这是多 Agent 编排中最常见的失败原因

5.1 问题本质

主代理(完整上下文)
  │
  ├── 知道项目结构
  ├── 知道讨论历史
  ├── 知道用户需求
  └── 知道前面的决策

子代理(空白上下文)
  │
  ├── 不知道项目结构  ← 问题!
  ├── 不知道讨论历史  ← 问题!
  ├── 不知道用户需求  ← 需要手动传递
  └── 不知道前面的决策 ← 问题!

主代理有完整的对话上下文,但子代理是从零开始的。如果你只给子代理一句话的指令,它会"盲人摸象" — 基于不完整的信息做出决策。

5.2 常见失败模式

主代理:"让子代理审查 auth 模块的安全性"
子代理:只读了 auth.js 就给出结论
        ← 没有读 middleware.js 中的权限检查
        ← 没有读 .env.example 中的密钥配置
        ← 结论不完整甚至错误

最危险的失败:主代理盲目接受子代理的不完整结果。

5.3 解决方案:迭代检索模式

主代理 → 子代理(初始指令 + 必要上下文)
              │
              ▼
         子代理执行
              │
              ▼
         主代理审查结果
              │
              ├── 结果完整且正确 → 接受
              │
              └── 结果不完整 → 追问(最多 3 次)
                    │
                    ▼
              "你漏看了 middleware.js,
               请补充审查这个文件的
               权限检查逻辑"
                    │
                    ▼
              子代理补充分析
                    │
                    ▼
              主代理再次审查
              ...(最多 3 次循环)

三条铁律

  1. 给足上下文 — 给子代理的指令必须包含足够的项目上下文(相关文件路径、技术栈、已有决策)
  2. 验证结果 — 不要盲目接受子代理的输出,主代理必须审查完整性
  3. 限制循环 — 追问最多 3 次。如果 3 次仍不完整,说明任务拆分不当,需要重新设计

六、Git Worktree 隔离

6.1 问题:多 Agent 修改同一仓库

当多个 Agent 并行工作且都需要修改文件时,会出现冲突:

Agent A 修改 auth.js 的第 50 行
Agent B 修改 auth.js 的第 52 行
→ 冲突!

6.2 解决方案:Git Worktree

Git Worktree 允许同一个仓库创建多个独立的工作目录,每个目录有自己的文件系统但共享 Git 历史:

project/                    ← 主工作区(Agent A)
project-worktree-b/         ← 独立工作区(Agent B)
project-worktree-c/         ← 独立工作区(Agent C)

三个目录各自独立修改文件
互不干扰
最后通过 Git 合并

6.3 操作流程

# 1. 为每个 Agent 创建独立的 worktree
git worktree add ../project-agent-b -b feature/agent-b
git worktree add ../project-agent-c -b feature/agent-c

# 2. 每个 Agent 在自己的 worktree 中工作
# Agent A: 在 project/ 中工作
# Agent B: 在 project-agent-b/ 中工作
# Agent C: 在 project-agent-c/ 中工作

# 3. 工作完成后合并
git merge feature/agent-b
git merge feature/agent-c

# 4. 清理 worktree
git worktree remove ../project-agent-b
git worktree remove ../project-agent-c

6.4 并行数量建议

推荐最大并行数:3-4 个 Agent

理由:
- 每个 worktree 占用磁盘空间
- 每个 Agent 消耗上下文窗口
- 合并冲突随并行数指数增长
- 超过 4 个后管理成本 > 并行收益
并行数 管理难度 合并风险 建议
2 推荐
3 适合有经验者
4 最大推荐值
>4 极高 极高 不推荐

6.5 Worktree vs 分支

普通分支:
  切换分支需要 checkout → 文件系统变化 → Agent 上下文混乱

Git Worktree:
  每个 Agent 有独立目录 → 同时存在 → 不需要切换 → 互不干扰

Worktree 是并行 Agent 的最佳实践,因为它在文件系统级别实现了隔离。


七、编排设计模式

7.1 钻石模式

最常见的多 Agent 编排模式:

        ┌── Agent A ──┐
        │             │
输入 ───┼── Agent B ──┼── 汇总 → 输出
        │             │
        └── Agent C ──┘

前面一个分发节点、中间并行执行、后面一个汇总节点。适用于多维度审查、多方案评估。

7.2 管道模式

输入 → Agent A → Agent B → Agent C → 输出

每个 Agent 处理一个阶段,输出传给下一个。适用于有明确顺序的工作流(如 Plan → TDD → Review)。

7.3 混合模式

输入 → Plan Agent → ┬─ Dev Agent A ──┬─ Review Agent → 输出
                     │                │
                     └─ Dev Agent B ──┘

先顺序(规划),再并行(开发),再顺序(审查)。这是现实中最常见的模式。


八、本课练习

练习 1:并行判断(10 分钟)

判断以下 5 组任务能否并行执行,说明理由:

  1. 审查 CSS 样式 + 审查 API 安全性
  2. 修改数据库 Schema + 修改 ORM 模型
  3. 为 Go 模块写测试 + 为 Python 模块写测试
  4. 重构函数 A + 重构调用函数 A 的函数 B
  5. 翻译中文文档 + 翻译日文文档

练习 2:设计编排方案(20 分钟)

设计以下 3 个场景的多 Agent 编排方案。要求画出编排图并说明:

  • 哪些步骤可以并行
  • 为什么不能改为顺序执行
  • 每个 Agent 的模型选择

场景 A:一个电商网站需要同时审查前端(React)、后端(Node.js)和数据库(PostgreSQL)的代码变更

场景 B:一个开源库发布新版本,需要同时更新 npm/PyPI/crates.io 的发布文件、更新 CHANGELOG、更新文档

场景 C:一个安全事件响应,需要同时扫描所有微服务的密钥泄露、检查所有 API 端点的认证、审计所有数据库的访问日志

练习 3:子代理指令设计(15 分钟)

为以下子代理任务设计指令。要求指令中包含足够的上下文信息,避免子代理上下文丢失:

任务:让一个子代理审查 src/auth/oauth.js 的安全性

写出你会传给子代理的完整指令(不只是"审查这个文件的安全性")。

练习 4(选做):Git Worktree 实践

在一个测试仓库中:

  1. 创建两个 Worktree
  2. 在每个 Worktree 中修改不同的文件
  3. 合并两个 Worktree 的变更
  4. 清理 Worktree

记录整个过程和遇到的问题。


九、本课小结

你应该记住的 内容
并行判断标准 无数据依赖 + 不修改同一文件 + 决策独立 → 并行
四种编排模式 单 Agent、并行 Agent、多模型协作、级联执行
多视角分析 5 种角色:事实审查、高级工程师、安全专家、一致性、冗余
上下文丢失 子代理无上下文 → 必须手动传递 + 迭代验证(最多 3 次)
Git Worktree 每个 Agent 独立工作区,推荐最大 3-4 个并行
最危险的失败 盲目接受子代理的不完整结果

十、下节预告

第 17 课:Hook 事件链 — 自动化的骨架

多 Agent 编排是手动触发的协作,但 ECC 还有一种自动触发的协作机制 — Hook 事件链。下节课我们将深入 7 种 Hook 事件类型,理解 PreToolUse / PostToolUse / SessionStart / SessionEnd / PreCompact / Stop 各自的触发时机和实际用途。

预习建议:阅读 rules/common/hooks.md,并查看 .claude/settings.json 中的 Hook 配置。

第 15 课:会话管理 — 上下文、模型与持久化

作者 王小酱
2026年4月8日 23:13

所属阶段:第三阶段「工作流实战」(第 12-20 课) 前置条件:第 10-11 课(Hooks 系统、脚本层) 本课收获:能根据任务选模型,能在三种动态上下文间切换


一、本课概述

前面几课聚焦于"怎么写代码"。本课聚焦于一个同样重要但常被忽视的问题:怎么管理你的 AI 助手

AI 编程助手有两个关键限制:

  1. 上下文窗口有限 — 不是无限记忆,信息会被挤出去
  2. 模型选择影响成本和质量 — 用错模型要么太贵要么太弱

本课回答三个问题:

  1. 上下文窗口怎么管理? — 安全区 vs 危险区,Strategic Compact
  2. 模型怎么选? — 三层模型策略
  3. 会话状态怎么持久化? — 三件套 Hook 系统

学完本课,你将能根据任务类型选择合适的模型,管理上下文窗口避免信息丢失,并在会话之间保持工作连续性。


二、上下文窗口管理

2.1 安全区与危险区

AI 编程助手的上下文窗口不是一个均匀的空间。越接近上限,模型的表现越不稳定:

┌────────────────────────────────────────────────┐
│              上下文窗口(200K tokens)            │
│                                                 │
│  ┌───────────────────────────────────────────┐  │
│  │          安全区 0% - 80%                   │  │
│  │                                           │  │
│  │  ✓ 多文件重构                              │  │
│  │  ✓ 功能实现(跨文件)                       │  │
│  │  ✓ 复杂调试                                │  │
│  │  ✓ 架构讨论                                │  │
│  │                                           │  │
│  ├───────────────────────────────────────────┤  │
│  │          危险区 80% - 100%                 │  │
│  │                                           │  │
│  │  △ 仅限单文件编辑                          │  │
│  │  △ 独立工具创建                            │  │
│  │  △ 文档更新                                │  │
│  │  △ 简单 Bug 修复                           │  │
│  │  ✗ 不要做多文件重构                         │  │
│  │  ✗ 不要做复杂调试                           │  │
│  └───────────────────────────────────────────┘  │
└────────────────────────────────────────────────┘

关键规则:避免在上下文窗口的最后 20% 做复杂任务。

当你感觉 AI 开始"忘记"前面讨论过的内容、重复已经做过的事情、或者答非所问,很可能是上下文窗口接近满了。

2.2 MCP 的上下文陷阱

这是一个很多人不知道的陷阱:

启用超过 10 个 MCP 服务器时,有效上下文从 200K 压缩到约 70K。

每个 MCP 服务器的工具定义会占用上下文空间。10 个 MCP 各带 10 个工具 = 100 个工具定义,每个定义几百 tokens,光是工具声明就占去了大量空间。

MCP 数量 有效上下文 建议
0-3 个 ~200K 安全
4-7 个 ~150K 注意
8-10 个 ~100K 警告
>10 个 ~70K 危险 — 优先用 CLI + Skills 替代

解决方案

  • 优先使用 CLI 工具和 Skills 替代 MCP
  • 只启用当前任务需要的 MCP
  • 使用 /context-budget 命令监控上下文使用情况

2.3 Strategic Compact — 手动压缩

当上下文接近 80% 时,你有两个选择:

  1. 自动压缩 — 系统自动丢弃"不重要"的信息(不可控,可能丢失关键上下文)
  2. Strategic Compact — 在逻辑断点手动触发压缩(可控,保留关键信息)
自动压缩(被动):
  上下文满了 → 系统自动裁剪 → 可能丢失你需要的信息 → 质量下降

Strategic Compact(主动):
  完成一个逻辑阶段 → 手动压缩 → 保留关键结论 → 开始下一阶段

最佳时机

  • 完成一个功能的 TDD 循环后
  • 代码审查结束,修复所有问题后
  • 切换到不同的功能/文件时
  • 讨论完架构方案、做出决策后

核心原则:Strategic Compact 优于自动压缩。在每个逻辑断点主动压缩,而不是等到系统被迫压缩。


三、模型分层选择

3.1 三层模型表

ECC 在 performance.md 中定义了三层模型选择策略:

模型 定位 能力占比 成本 适用场景
Haiku 4.5 轻量高频 Sonnet 的 90% 最低(Sonnet 的 1/3) 高频调用的 Worker Agent、Pair Programming、代码生成
Sonnet 4.6 主力开发 最佳编程模型 中等 日常开发、编排多 Agent、复杂编码
Opus 4.5 深度推理 最强推理 最高 架构决策、复杂分析、研究任务

3.2 选择决策树

任务到来
    │
    ├─ 是否需要深度推理/架构决策?
    │   └─ 是 → Opus 4.5
    │
    ├─ 是否是日常开发/代码审查/编排?
    │   └─ 是 → Sonnet 4.6
    │
    └─ 是否是高频轻量任务/Worker Agent?
        └─ 是 → Haiku 4.5

3.3 ECC Agent 中的模型选择

看看 ECC 的 Agent 是怎么选模型的:

Agent Model 原因
planner opus 规划需要最强推理能力
architect opus 架构决策需要深度分析
tdd-guide sonnet 日常开发任务
code-reviewer sonnet 代码审查是常规操作
build-error-resolver sonnet 修复错误需要编码能力

关键发现:只有需要"想清楚"的 Agent 用 Opus(planner、architect),需要"做出来"的 Agent 用 Sonnet。

3.4 成本优化建议

成本 = 模型单价 × Token 消耗

降低成本的三个杠杆:
1. 模型降级:能用 Haiku 就不用 Sonnet,能用 Sonnet 就不用 Opus
2. 减少 Token:Strategic Compact、精简 Prompt、减少不必要的上下文
3. 减少 MCP:用 CLI + Skills 替代 MCP,减少工具定义的 Token 消耗

四、会话持久化三件套

ECC 通过三个 Hook 实现会话状态的持久化:

4.1 三件套概览

┌────────────────────────────────────────────────┐
│              会话持久化三件套                     │
│                                                 │
│  ┌─────────────────┐                            │
│  │ SessionEnd Hook │  会话结束时自动保存          │
│  │ "保存当前进度"    │  → 存储到 .claude/sessions/ │
│  └────────┬────────┘                            │
│           │                                     │
│           ▼                                     │
│  ┌─────────────────┐                            │
│  │SessionStart Hook│  新会话开始时自动恢复         │
│  │ "恢复上次进度"    │  ← 读取 .claude/sessions/  │
│  └────────┬────────┘                            │
│           │                                     │
│           ▼                                     │
│  ┌─────────────────┐                            │
│  │PreCompact Hook  │  压缩前自动保存快照          │
│  │ "压缩前保存"     │  → 防止压缩丢失关键信息     │
│  └─────────────────┘                            │
└────────────────────────────────────────────────┘

4.2 工作流程

会话 A:
  工作中... → 上下文接近 80%
  │
  ├─ PreCompact Hook 触发 → 保存当前状态快照
  │
  └─ 继续工作... → 会话结束
      │
      └─ SessionEnd Hook 触发 → 保存最终状态

会话 B(新的会话):
  │
  ├─ SessionStart Hook 触发 → 恢复会话 A 的状态
  │
  └─ 从上次中断的地方继续

4.3 手动命令

除了自动 Hook,你也可以手动管理会话:

命令 作用
/save-session 手动保存当前会话状态
/resume-session 恢复指定的历史会话
/sessions 查看所有保存的会话列表
/checkpoint 创建一个命名检查点(比 save-session 更精细)

最佳实践

  • 完成一个重要阶段后手动 /save-session
  • 在做有风险的操作前 /checkpoint
  • 开始新会话时用 /resume-session 恢复上下文

五、动态上下文三模式

ECC 支持根据任务类型切换不同的上下文模式。每种模式会加载不同的指令集,引导 AI 以不同的方式工作:

5.1 三种模式

模式 上下文文件 工作方式 适用场景
dev contexts/dev.md 代码优先 — 快速编码、最小讨论 功能实现、Bug 修复
review contexts/review.md 审查模式 — 仔细检查、分级报告 代码审查、PR 审查
research contexts/research.md 先理解再行动 — 广泛搜索、深入分析 技术调研、架构决策

5.2 模式对比

同一个任务在不同模式下的行为差异:

任务:"这个函数有性能问题"

模式 行为
dev 直接定位瓶颈、写优化代码、跑基准测试
review 分析代码复杂度、列出所有潜在问题、给出分级建议
research 搜索类似场景的解决方案、比较多种优化策略、写分析报告

5.3 切换时机

开始新功能 → research 模式(先理解需求和技术方案)
     │
     ▼
方案确定 → dev 模式(快速编码实现)
     │
     ▼
代码写完 → review 模式(仔细审查质量和安全)
     │
     ▼
修复问题 → dev 模式(快速修复审查发现的问题)

六、Extended Thinking

6.1 什么是 Extended Thinking

Extended Thinking 允许模型在给出回答之前进行更深入的内部推理。默认开启,预留最多 31,999 tokens 用于思考。

普通模式:
  问题 → 直接回答

Extended Thinking 模式:
  问题 → [内部推理 31,999 tokens] → 更深思熟虑的回答

6.2 控制方式

操作 方法
切换开关 Option+T(macOS)/ Alt+T(Windows/Linux)
配置文件 ~/.claude/settings.json 中设置 alwaysThinkingEnabled
预算上限 export MAX_THINKING_TOKENS=10000
查看思考过程 Ctrl+O 启用 Verbose 模式

6.3 Plan Mode

当面对复杂任务时,可以结合 Extended Thinking 使用 Plan Mode:

  1. 开启 Extended Thinking(默认已开启)
  2. 启用 Plan Mode — AI 会先输出结构化计划
  3. 多轮审查 — 让 AI 从不同角度检视计划
  4. 确认后执行

Plan Mode 特别适合:

  • 涉及多文件的重构
  • 架构变更
  • 不确定最佳方案的场景

七、综合策略表

根据任务类型,选择合适的模型、上下文模式和管理策略:

任务类型 推荐模型 上下文模式 上下文策略
架构决策 Opus 4.5 research 开启 Extended Thinking + Plan Mode
新功能开发 Sonnet 4.6 dev 每个 TDD 循环后 Strategic Compact
代码审查 Sonnet 4.6 review 审查结束后 Compact
Bug 修复 Sonnet 4.6 dev 单次会话完成
Worker Agent Haiku 4.5 dev 最小上下文,频繁调用
技术调研 Opus 4.5 research 广泛搜索,保留结论
文档更新 Haiku 4.5 dev 低上下文敏感度

八、本课练习

练习 1:模型选择判断(10 分钟)

为以下 6 个场景选择合适的模型,并说明理由:

  1. 为一个 React 组件写单元测试
  2. 设计一个微服务架构方案
  3. 批量重命名 20 个文件中的变量
  4. 审查一个包含安全敏感代码的 PR
  5. 研究是否应该从 REST 迁移到 GraphQL
  6. 修复一个 CSS 样式 Bug

练习 2:上下文管理模拟(15 分钟)

假设你正在用 Claude Code 开发一个新功能,上下文窗口已经用到 75%。你需要:

  1. 决定是否要 Strategic Compact
  2. 列出你会保留的关键信息
  3. 列出你可以安全丢弃的信息
  4. 写出 Compact 后的"恢复摘要"

练习 3:三模式对比(20 分钟)

选一段你自己写过的代码(或本课程前面练习的代码),用三种不同的上下文模式分别处理同一个改进任务:

  1. dev 模式:直接优化代码
  2. review 模式:审查并列出所有问题
  3. research 模式:研究最佳实践后给出建议

对比三种模式的输出差异。

练习 4(选做):计算 MCP 成本

列出你当前启用的所有 MCP 服务器,估算它们总共占用多少上下文空间。如果超过 10 个,找出哪些可以用 CLI + Skills 替代。


九、本课小结

你应该记住的 内容
上下文安全区 0-80% 正常工作,80-100% 仅限简单任务
Strategic Compact 在逻辑断点主动压缩,优于自动压缩
模型三层 Haiku(轻量高频)、Sonnet(主力开发)、Opus(深度推理)
MCP 陷阱 >10 个 MCP 时上下文从 200K 压到 ~70K
持久化三件套 SessionEnd 保存、SessionStart 恢复、PreCompact 压缩前保存
三种上下文模式 dev(代码优先)、review(审查模式)、research(先理解再行动)
Extended Thinking 默认开启 31,999 tokens,Option+T 切换

十、下节预告

第 16 课:多代理编排 — 并行、视角与隔离

到目前为止,我们一直在使用单个 Agent。下节课我们将学习如何让多个 Agent 协作:什么时候并行、什么时候顺序、如何用多视角分析同一个问题、如何用 Git Worktree 隔离不同 Agent 的工作区。这是 ECC 最强大也最复杂的能力。

预习建议:阅读 rules/common/agents.md,特别是 Parallel Task Execution 和 Multi-Perspective Analysis 两节。

第 14 课:验证循环 — 从代码到可提交

作者 王小酱
2026年4月8日 23:13

所属阶段:第三阶段「工作流实战」(第 12-20 课) 前置条件:第 13 课(TDD 全流程) 本课收获:一次完整通过验证循环的代码提交


一、本课概述

代码写完了、测试通过了,能提交吗?在 ECC 的世界里,答案是不能

测试通过只证明功能正确,但没有回答:代码风格规范吗?类型安全吗?有没有泄露密钥?有没有 SQL 注入?这些问题需要验证循环来回答。

本课回答三个问题:

  1. 验证循环有几步? — 四步检查 + 一步提交
  2. 每步防范什么风险? — 从逻辑错误到密钥泄露
  3. 失败了怎么办? — 修复后从头重跑,没有捷径

学完本课,你将能独立完成一次从代码到提交的完整验证流程。


二、验证循环四步

2.1 全景图

┌────────────────────────────────────────────────────────┐
                    验证循环                              
                                                         
  ┌──────┐   ┌──────┐   ┌──────────┐   ┌──────────┐    
   TEST    LINT    TYPECHECK│   SECURITY     
  │测试     │风格     │类型检查      │安全检查       
  └──┬───┘   └──┬───┘   └────┬─────┘   └────┬─────┘    
                                                    
                                                    
   PASS?      PASS?       PASS?          PASS?         
                                               
  Yes No     Yes No      Yes No         Yes No         
                                               
     └─→ 修复  从头重跑    └─→ 修复       └─→ 修复  
                                                   
                                                   
             全部 PASS  可提交                          
└────────────────────────────────────────────────────────┘

2.2 每步防范什么

步骤 检查内容 防范的风险 典型工具
TEST 单元/集成/E2E 测试 逻辑错误、边界错误、回归 Jest、Vitest、Mocha、pytest
LINT 代码风格、格式、反模式 风格不一致、潜在 Bug(未使用变量等) ESLint、Prettier、Ruff、golint
TYPECHECK 类型安全 类型不匹配、空值异常、接口不兼容 tsc、mypy、go vet
SECURITY 密钥泄露、注入、依赖漏洞 密钥泄露、SQL 注入、XSS、已知 CVE security-reviewer Agent、npm audit

2.3 为什么是这个顺序

顺序不是随意的,而是按发现成本从低到高排列:

  1. TEST 最先 — 如果逻辑都不对,检查风格毫无意义
  2. LINT 第二 — 风格问题最容易发现和修复
  3. TYPECHECK 第三 — 类型错误可能需要修改接口,影响范围较大
  4. SECURITY 最后 — 安全检查最严格,也最耗时

三、每步详解

3.1 TEST — 测试通过

这一步在第 13 课已经详细讲过。要点回顾:

# 运行所有测试
npm test

# 带覆盖率
npm test -- --coverage

# 要求:80%+ 覆盖率,所有测试 PASS

失败时:

  • 修复实现代码,不是修复测试(除非测试本身写错了)
  • 使用 tdd-guide Agent 辅助排查
  • 检查测试隔离性 — 是否有共享状态导致的耦合

3.2 LINT — 风格检查

Lint 检查代码是否符合项目约定的风格规范:

# JavaScript/TypeScript
npx eslint .

# Python
ruff check .

# Go
golangci-lint run

# Markdown(ECC 项目自身)
npx markdownlint-cli '**/*.md' --ignore node_modules

ECC 项目中的具体 Lint 配置

  • 使用 @eslint/js flat config
  • Markdown 使用 markdownlint-cli
  • 所有 Lint 检查必须在提交前通过

失败时:

  • 大部分 Lint 错误可以自动修复:npx eslint . --fix
  • 手动修复后重新运行 Lint
  • 不要用 // eslint-disable 绕过检查(除非有充分理由且加注释说明)

3.3 TYPECHECK — 类型检查

类型检查确保代码的类型安全:

# TypeScript
npx tsc --noEmit

# Python
mypy .

# Go(内置)
go vet ./...

失败时:

  • 类型错误通常意味着接口设计有问题
  • 不要用 any(TypeScript)或 # type: ignore(Python)绕过
  • 如果确实需要绕过,加注释说明原因

3.4 SECURITY — 安全检查

这是验证循环中最严格的一步。ECC 的 security.md 定义了 8 项强制安全检查:

提交前安全检查清单:
  □ 无硬编码密钥(API Key、密码、Token)
  □ 所有用户输入已验证
  □ SQL 注入防护(参数化查询)
  □ XSS 防护(HTML 已消毒)
  □ CSRF 保护已启用
  □ 认证/授权已验证
  □ 所有端点有速率限制
  □ 错误消息不泄露敏感数据

工具辅助:

# 依赖漏洞扫描
npm audit

# 密钥扫描
git diff --cached | grep -i "api_key\|secret\|password\|token"

# 使用 security-reviewer Agent
# 会自动扫描 CRITICAL 安全问题

失败时 — 安全响应协议

  1. 立即停止 — 不要继续提交
  2. 调用 security-reviewer Agent
  3. 修复所有 CRITICAL 问题
  4. 轮换任何可能已暴露的密钥
  5. 审查整个代码库是否有类似问题

四、任何一步失败 → 从头重跑

这是验证循环最重要的规则:

TEST 失败 → 修复 → 重跑 TEST → LINT → TYPECHECK → SECURITY
LINT 失败 → 修复 → 重跑 TEST → LINT → TYPECHECK → SECURITY
TYPECHECK 失败 → 修复 → 重跑 TEST → LINT → TYPECHECK → SECURITY
SECURITY 失败 → 修复 → 重跑 TEST → LINT → TYPECHECK → SECURITY

为什么修复后要从头重跑?

因为修复一个问题可能引入新的问题:

  • 修复类型错误可能破坏现有测试
  • 修复安全问题可能改变代码逻辑
  • 修复 Lint 错误可能改变代码格式影响行为

只有从头到尾全部 PASS,代码才是可提交的。


五、Conventional Commits 格式

验证循环通过后,就可以提交了。ECC 要求使用 Conventional Commits 格式:

5.1 格式

<type>: <description>

<optional body>

5.2 类型速查表

类型 用途 示例
feat 新功能 feat: add slugify utility function
fix Bug 修复 fix: handle empty string in slugify
refactor 重构(不改行为) refactor: extract regex patterns to constants
docs 文档 docs: add JSDoc for slugify function
test 测试 test: add edge case tests for slugify
chore 杂项(构建、依赖) chore: update eslint config
perf 性能优化 perf: cache compiled regex in slugify
ci CI/CD ci: add coverage check to GitHub Actions

5.3 好的提交信息 vs 差的提交信息

# BAD — 描述做了什么(what)
git commit -m "update slugify.js"

# BAD — 太笼统
git commit -m "fix bug"

# GOOD — 描述为什么这样做(why)
git commit -m "fix: handle consecutive spaces in slugify to prevent double hyphens"

# GOOD — 带正文补充上下文
git commit -m "feat: add input validation to slugify

TypeError is thrown for non-string arguments to fail fast
at the call site rather than producing unexpected results
from implicit type coercion."

六、代码质量清单

在验证循环之外,coding-style.md 还定义了一份代码质量清单。这不是自动化检查,而是人工确认

提交前代码质量清单:
  □ 代码可读、命名清晰
  □ 函数短小(<50 行)
  □ 文件聚焦(<800 行)
  □ 无深层嵌套(>4 层)
  □ 错误处理完备
  □ 无硬编码值(使用常量或配置)
  □ 无 mutation(使用不可变模式)

6.1 每项的判断标准

检查项 通过标准 常见违规
可读性 变量名表达含义,逻辑清晰 单字母变量、嵌套三元表达式
函数 <50 行 用行数计算,不含空行和注释 一个函数做太多事
文件 <800 行 200-400 行是理想范围 所有逻辑放一个文件
嵌套 <4 层 if/for/while 的嵌套深度 用 early return 拍平
错误处理 每个可能失败的操作都有处理 空 catch 块、忽略 Promise rejection
无硬编码 数字和字符串提取为常量 if (retries > 3) 而非 MAX_RETRIES
无 mutation 使用 spread、map、filter 而非修改 array.push() 而非 [...array, item]

七、完整验证循环示例

用第 13 课写的 slugify 代码走一遍完整流程:

# Step 1: TEST
node --test slugify.test.js
# ✓ 7 tests passed → PASS

# Step 2: LINT
npx eslint slugify.js slugify.test.js
# No errors → PASS

# Step 3: TYPECHECK(如果是 TypeScript 项目)
# npx tsc --noEmit
# 纯 JavaScript 项目可跳过此步

# Step 4: SECURITY
# 检查无硬编码密钥
grep -r "api_key\|secret\|password" slugify.js
# No matches → PASS

# 检查依赖漏洞
npm audit
# 0 vulnerabilities → PASS

# 全部 PASS → 可提交
git add slugify.js slugify.test.js
git commit -m "feat: add slugify utility with full test coverage

Implements URL-friendly text conversion with:
- Space to hyphen conversion
- Special character removal
- Consecutive hyphen collapsing
- Input type validation

Coverage: 100% (8/8 tests passing)"

八、本课练习

练习 1:完整验证循环(25 分钟)

用第 13 课写的 slugify 代码(或第 13 课练习中的 truncate 代码),完整执行一遍验证循环:

  1. 运行测试,确认全部 PASS
  2. 运行 Lint,修复所有问题
  3. 运行类型检查(如适用)
  4. 执行安全检查清单(逐项确认)
  5. 用 Conventional Commits 格式提交

记录每一步的输出和修复过程。

练习 2:故意失败(15 分钟)

slugify.js 中故意引入以下问题,然后尝试通过验证循环:

  1. 改变一个正则让测试失败
  2. 添加一个未使用的变量让 Lint 报错
  3. 硬编码一个假的 API Key 让安全检查报警

观察验证循环如何捕获每种问题。

练习 3:代码质量清单审查(10 分钟)

对照代码质量清单的 7 项,审查你在第 13 课写的代码:

  • 函数是否 <50 行?
  • 有没有硬编码值?
  • 有没有 mutation?
  • 命名是否清晰?

列出所有不符合项并修复。

练习 4(选做):审查一个开源项目

找一个你熟悉的开源项目的某个 PR,用 ECC 的代码质量清单和安全检查清单审查它。记录你发现了什么问题。


九、本课小结

你应该记住的 内容
验证循环四步 TEST → LINT → TYPECHECK → SECURITY → 可提交
失败处理 任何一步失败 → 修复 → 从头重跑
安全检查清单 8 项强制检查(密钥、输入验证、注入、XSS、CSRF、认证、限流、错误消息)
Conventional Commits <type>: <description> — feat/fix/refactor/docs/test/chore/perf/ci
代码质量清单 7 项人工确认(可读性、函数大小、文件大小、嵌套、错误处理、硬编码、mutation)
安全响应协议 停止 → security-reviewer → 修复 → 轮换密钥 → 全局排查

十、下节预告

第 15 课:会话管理 — 上下文、模型与持久化

代码写好了、提交了,但还有一个隐藏的问题:你的 AI 助手的上下文窗口是有限的。下节课我们将学习如何管理上下文窗口、选择合适的模型、在会话之间持久化状态。这些技能决定了你能否在大型项目中高效使用 ECC。

预习建议:阅读 rules/common/performance.md,特别是 Model Selection Strategy 和 Context Window Management 两节。

第 13 课:TDD 全流程 — RED-GREEN-IMPROVE

作者 王小酱
2026年4月8日 23:12

所属阶段:第三阶段「工作流实战」(第 12-20 课) 前置条件:第 12 课(调用链追踪) 本课收获:完整体验一次 TDD 循环,理解每个阶段的目的


一、本课概述

TDD(Test-Driven Development)不是 ECC 发明的,但 ECC 把它从"建议"变成了"强制"。在 ECC 的世界里,没有测试的代码 = 不存在的代码。

本课回答三个问题:

  1. TDD 在整个开发流程中处于什么位置? — 四阶段开发流
  2. TDD 的每个阶段到底做什么? — RED-GREEN-IMPROVE-VERIFY 详解
  3. 如何亲手执行一次完整的 TDD 循环? — 从零写一个 slugify 函数

学完本课,你将能独立完成一次严格的 TDD 循环,包括测试先行、最小实现、重构优化和覆盖率验证。


二、四阶段开发流

在 ECC 的 development-workflow.md 中定义了完整的功能开发流程。TDD 不是孤立的,它是四阶段开发流中的第二步:

阶段 0:Research & Reuse(研究与复用)
    │ GitHub 搜索、库文档、包注册表
    │ "找到能解决 80%+ 问题的现有实现"
    ▼
阶段 1:Plan(规划)                    ← planner Agent
    │ 需求分析 → 架构设计 → 步骤拆解
    │ "必须等用户确认后才动手"
    ▼
阶段 2:TDD(测试驱动开发)              ← tdd-guide Agent
    │ RED → GREEN → IMPROVE → VERIFY
    │ "测试先行,80%+ 覆盖率"
    ▼
阶段 3:Review(代码审查)               ← code-reviewer Agent
    │ 安全检查 → 质量检查 → 分级报告
    │ "CRITICAL 和 HIGH 必须修"
    ▼
阶段 4Commit & Push(提交)
    │ Conventional Commits 格式
    │ CI/CD 通过后才能合并
    ▼
阶段 5:Pre-Review Checks(合并前检查)
    │ 自动检查通过 → 解决冲突 → 请求 Review

关键洞察:TDD 之前必须有 Plan,TDD 之后必须有 Review。跳过任何一步都会导致后续步骤的质量下降。


三、TDD 严格循环详解

3.1 四个阶段

ECC 的 TDD 循环比传统的 RED-GREEN-REFACTOR 多了一步 VERIFY:

┌─────────────────────────────────────────────────┐
│                TDD 严格循环                       │
│                                                  │
│  ┌──────┐    ┌──────┐    ┌──────────┐   ┌──────┐│
│  │ RED  │ →  │GREEN │ →  │ IMPROVE  │ → │VERIFY││
│  │写测试│    │最小实现│    │重构优化   │   │覆盖率 ││
│  │必须失败│   │刚好通过│    │测试仍绿  │   │80%+  ││
│  └──────┘    └──────┘    └──────────┘   └──────┘│
│      ▲                                     │     │
│      └─────── 下一个功能点 ◀───────────────┘     │
└─────────────────────────────────────────────────┘

3.2 RED 阶段 — 写测试,必须失败

做什么:写一个描述期望行为的测试,然后运行它,确认它失败

为什么测试必须先失败?

这是 TDD 中最反直觉但最重要的要求。理由有三:

  1. 验证测试本身有效 — 如果测试在实现之前就通过了,说明测试写错了(可能断言太宽松,或者测试的功能已经存在)
  2. 确认因果关系 — 只有先看到"失败",再看到"通过",你才能确信是你写的代码让测试通过的,而不是其他原因
  3. 明确需求 — 写不出失败测试 = 你还没想清楚要做什么(回到 Plan 阶段)
RED 阶段 = 需求定义阶段

测试写得出来 → 需求明确 → 继续
测试写不出来 → 需求不清 → 回到 Plan

3.3 GREEN 阶段 — 最小实现,刚好通过

做什么:写最少的代码让测试通过。不多写一行。

为什么是"最少"的代码?

  • 避免过度工程化 — 只实现测试要求的行为,不猜测未来需求(YAGNI)
  • 保持每步可验证 — 小步前进,每步都有测试保驾护航
  • 为重构留空间 — GREEN 阶段的代码可以很丑,重构在 IMPROVE 阶段做

常见错误:GREEN 阶段就开始优化代码结构。这是错误的 — 先让测试通过,再优化。

3.4 IMPROVE 阶段 — 重构优化,测试仍绿

做什么:在测试通过的保护下,改善代码质量。每次修改后运行测试,确保测试仍然通过。

可以做的事:

  • 提取常量(消除 Magic Numbers)
  • 重命名变量(提高可读性)
  • 提取辅助函数(降低复杂度)
  • 消除重复代码(DRY)
  • 改用不可变模式(Immutability)

铁律:重构期间测试必须保持绿色。如果测试变红了,说明重构改变了行为 — 回退并重来。

3.5 VERIFY 阶段 — 覆盖率检查

做什么:运行覆盖率工具,确保达到 80% 以上的覆盖率。

# Node.js 项目
npm test -- --coverage

# 覆盖率要求
# 常规代码:80%+
# 关键代码(金融计算、认证逻辑、安全相关):100%

四、AAA 模式(Arrange-Act-Assert)

每个测试用例都应该遵循 AAA 模式。这是 ECC testing.md 中明确推荐的结构:

test('returns empty array when no markets match query', () => {
  // Arrange — 准备测试数据和环境
  const markets = [
    { name: 'BTC-USD', volume: 1000 },
    { name: 'ETH-USD', volume: 500 },
  ];
  const query = 'DOGE';

  // Act — 执行被测行为
  const result = filterMarkets(markets, query);

  // Assert — 验证结果
  expect(result).toEqual([]);
});

4.1 三个阶段的职责

阶段 职责 常见错误
Arrange 准备输入数据、Mock 依赖、设置初始状态 使用过于复杂的 Mock
Act 调用被测函数/方法,仅此一步 在 Act 中做多个操作
Assert 验证输出、副作用、异常 断言太宽松(如只检查非空)

4.2 测试命名规范

ECC 推荐使用描述行为的测试名称:

// GOOD — 描述被测行为和条件
test('returns empty array when no markets match query', () => {});
test('throws error when API key is missing', () => {});
test('falls back to substring search when Redis is unavailable', () => {});

// BAD — 描述实现细节
test('test filterMarkets function', () => {});
test('test error', () => {});
test('test Redis fallback', () => {});

命名公式:{行为} when {条件}


五、实战:slugify 函数的 TDD 循环

下面我们完整走一遍 TDD 循环,实现一个 slugify 函数(将文本转为 URL 友好的格式)。

5.1 RED — 写测试,确认失败

// slugify.test.js
const { slugify } = require('./slugify');

describe('slugify', () => {
  test('converts spaces to hyphens', () => {
    // Arrange
    const input = 'hello world';

    // Act
    const result = slugify(input);

    // Assert
    expect(result).toBe('hello-world');
  });

  test('converts to lowercase', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  test('removes special characters', () => {
    expect(slugify('hello! @world#')).toBe('hello-world');
  });

  test('collapses consecutive hyphens', () => {
    expect(slugify('hello   world')).toBe('hello-world');
  });

  test('trims leading and trailing hyphens', () => {
    expect(slugify(' hello world ')).toBe('hello-world');
  });

  test('returns empty string for empty input', () => {
    expect(slugify('')).toBe('');
  });
});

运行测试:

node --test slugify.test.js

# 预期输出:6 个测试全部 FAIL
# Error: Cannot find module './slugify'

确认失败。测试本身是有效的 — 它在期望一个尚不存在的模块。

5.2 GREEN — 最小实现

// slugify.js
function slugify(text) {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '');
}

module.exports = { slugify };

运行测试:

node --test slugify.test.js

# 预期输出:6 个测试全部 PASS

确认通过。注意这里的实现是"刚好够用"的 — 没有处理 Unicode、没有做参数校验、没有导出类型定义。

5.3 IMPROVE — 重构优化

// slugify.js — 重构后
const PATTERNS = {
  NON_ALPHANUMERIC: /[^a-z0-9\s-]/g,
  WHITESPACE: /\s+/g,
  CONSECUTIVE_HYPHENS: /-+/g,
  LEADING_TRAILING_HYPHENS: /^-|-$/g,
};

function slugify(text) {
  if (typeof text !== 'string') {
    throw new TypeError('slugify expects a string argument');
  }

  return text
    .toLowerCase()
    .replace(PATTERNS.NON_ALPHANUMERIC, '')
    .replace(PATTERNS.WHITESPACE, '-')
    .replace(PATTERNS.CONSECUTIVE_HYPHENS, '-')
    .replace(PATTERNS.LEADING_TRAILING_HYPHENS, '');
}

module.exports = { slugify };

改进了什么:

  • 提取正则为命名常量(消除 Magic Patterns)
  • 添加参数类型检查(输入验证)
  • 保持不可变 — 每个 replace 返回新字符串,不修改原始值

运行测试确认仍然通过:

node --test slugify.test.js

# 预期输出:6 个测试仍然全部 PASS

此时可以补充一个类型检查的测试:

test('throws TypeError for non-string input', () => {
  expect(() => slugify(123)).toThrow(TypeError);
  expect(() => slugify(null)).toThrow(TypeError);
});

5.4 VERIFY — 覆盖率检查

npx c8 node --test slugify.test.js

# 预期输出:
# File        | % Stmts | % Branch | % Funcs | % Lines
# ------------|---------|----------|---------|--------
# slugify.js  |   100   |   100    |   100   |   100
#
# Coverage: 100% PASS (Target: 80%)

TDD 循环完成。


六、development-workflow.md 的完整流程

把 TDD 循环放回完整开发流程的上下文中:

0. Research & Reuse
   │ ├─ GitHub 搜索:gh search repos / gh search code
   │ ├─ 库文档:Context7 / 官方文档
   │ ├─ Exa:前两步不够时的补充
   │ └─ 包注册表:npm / PyPI / crates.io
   ▼
1. Plan(planner Agent, Opus 模型)
   │ ├─ 需求分析和重述
   │ ├─ 架构变更清单
   │ ├─ 分阶段实施步骤
   │ └─ 等待用户确认
   ▼
2. TDD(tdd-guide Agent, Sonnet 模型)
   │ ├─ RED:写失败测试
   │ ├─ GREEN:最小实现
   │ ├─ IMPROVE:重构
   │ └─ VERIFY:80%+ 覆盖率
   ▼
3. Review(code-reviewer Agent, Sonnet 模型)
   │ ├─ CRITICAL / HIGH / MEDIUM / LOW 分级
   │ └─ 修复 CRITICAL 和 HIGH
   ▼
4. Commit
   │ ├─ Conventional Commits 格式
   │ └─ feat / fix / refactor / docs / test / chore / perf / ci
   ▼
5. Pre-Review Checks
   │ ├─ CI/CD 通过
   │ ├─ 冲突解决
   │ └─ 分支同步

七、TDD 反模式

ECC 的 tdd-guide.md 明确列出了必须避免的反模式:

反模式 为什么有害
先写实现再补测试 测试变成了"确认现有行为"而非"定义期望行为"
跳过 RED 阶段 无法确认测试本身是有效的
GREEN 阶段写太多代码 引入未经测试的行为,违反 YAGNI
测试实现细节而非行为 重构时测试就碎了,维护成本极高
测试之间共享状态 一个测试的失败会连锁影响其他测试
Mock 所有东西 测试只验证了 Mock 的行为,不验证真实行为
断言太少 测试通过但实际没验证任何有意义的事情

八、本课练习

练习 1:完整 TDD 循环(30 分钟)

严格按照 RED → GREEN → IMPROVE → VERIFY 的顺序,为以下函数完成一次 TDD 循环:

函数需求truncate(text, maxLength) — 截断文本到指定长度,超过时添加 "..."

测试用例提示:

  • 短于 maxLength 时原样返回
  • 等于 maxLength 时原样返回
  • 长于 maxLength 时截断并加 "..."
  • maxLength 小于 3 时的边界处理
  • 空字符串输入
  • 非字符串输入

严格要求

  1. 先写所有测试,运行确认全部 FAIL
  2. 写最少代码让测试通过
  3. 重构后再跑一遍测试
  4. 检查覆盖率

练习 2:识别反模式(10 分钟)

以下代码有什么 TDD 反模式?列出所有问题:

test('test the processOrder function', () => {
  const order = processOrder({ items: [{ id: 1, price: 10 }] });
  expect(order).toBeTruthy();
});

练习 3(选做):阅读 tdd-guide Agent

打开 agents/tdd-guide.md,回答:

  • 它的 model 是什么?为什么不用 Opus?
  • 它必须测试哪 8 种边界情况?
  • 它的质量检查清单有几项?

九、本课小结

你应该记住的 内容
四阶段开发流 Research → Plan → TDD → Review → Commit
TDD 四步 RED(写失败测试)→ GREEN(最小实现)→ IMPROVE(重构)→ VERIFY(80%+)
RED 阶段意义 验证测试有效、确认因果、明确需求
AAA 模式 Arrange(准备)→ Act(执行)→ Assert(断言)
测试命名 {行为} when {条件}
覆盖率要求 常规 80%+,关键代码 100%

十、下节预告

第 14 课:验证循环 — 从代码到可提交

TDD 只是验证的第一步。下节课我们将学习完整的验证循环:测试通过 → Lint 通过 → 类型检查通过 → 安全检查通过 → 才能提交。任何一步失败都要从头重跑。你会用第 13 课写的代码完整走一遍这个流程。

预习建议:阅读 rules/common/coding-style.md 的 Code Quality Checklist 和 rules/common/security.md 的 Mandatory Security Checks。

第 12 课:调用链追踪 — 从 Command 到执行

作者 王小酱
2026年4月8日 23:11

所属阶段:第三阶段「工作流实战」(第 12-20 课) 前置条件:第 4-11 课(所有组件已掌握) 本课收获:能追踪任意命令的完整调用链,理解组件如何串联


一、本课概述

学完六大组件之后,最关键的一步是理解它们如何串联。ECC 不是一堆独立的零件,而是一条精密的流水线。

本课回答三个问题:

  1. Command 是什么角色? — 它正在被 Skill 替代,为什么?
  2. 一个命令从输入到执行经历了哪些节点? — 追踪三条完整调用链
  3. 79 个命令怎么分类记忆? — 建立全局地图,重点记 5 个

理解调用链之后,你遇到任何 ECC 命令,都能快速定位它背后的 Skill、Agent 和 Rule。


二、Command 的角色定位

2.1 Legacy Slash-Entry Shim

打开任意一个 Command 文件(如 commands/tdd.md),你会看到这样的描述:

---
description: Legacy slash-entry shim for the tdd-workflow skill. Prefer the skill directly.
---

关键词:Legacy Slash-Entry Shim(遗留的斜杠入口垫片)。

这意味着 Command 的定位正在发生变化:

阶段 Command 的角色 说明
早期 主要入口 用户通过 /tdd 触发完整工作流
当前 兼容性垫片 工作流逻辑已迁移到 Skill,Command 只是转发
未来 可能淘汰 Skill 成为主要入口,Command 保留向后兼容

2.2 为什么迁移到 Skill

Command 是一个扁平的入口 — 它只能定义"做什么",但不擅长承载"怎么做"的详细知识。

Skill 是一个结构化的知识包 — 它包含 When to Use、How It Works、Examples 等完整的上下文。

Command(旧):/tdd → 直接内嵌工作流步骤(维护困难,容易过时)
Skill(新):/tdd → commands/tdd.md → skills/tdd-workflow/SKILL.md(逻辑集中维护)

迁移的好处:

  • 单一来源:工作流逻辑只在 Skill 中维护,Command 只做转发
  • 可复用:Agent 也可以直接引用 Skill,不需要通过 Command
  • 可测试:Skill 的结构化格式更容易验证正确性

三、调用链追踪 — 三个实例

3.1 调用链一:/tdd

这是最完整的调用链,涉及所有组件类型:

用户输入:/tdd

    │
    ▼
commands/tdd.md                    ← Command(入口垫片)
    │ "Apply the tdd-workflow skill"
    ▼
skills/tdd-workflow/SKILL.md       ← Skill(工作流知识)
    │ "Use tdd-guide agent"
    ▼
agents/tdd-guide.md                ← Agent(执行者)
    │ model: sonnet
    │ tools: Read, Write, Edit, Bash, Grep
    │ "Enforce RED-GREEN-REFACTOR"
    ▼
rules/common/testing.md            ← Rule(约束条件)
    │ "80% coverage, AAA pattern"
    ▼
rules/common/coding-style.md       ← Rule(代码质量)
    │ "Immutability, <50 lines per function"
    ▼
执行完毕 → 代码 + 测试 + 覆盖率报告

关键观察

  • Command 不做任何实质工作,只是一个路由器
  • Skill 承载完整的工作流定义
  • Agent 是实际的执行者,带有模型配置和工具权限
  • Rule 是 Agent 执行时必须遵守的约束

3.2 调用链二:/plan

用户输入:/plan

    │
    ▼
commands/plan.md                   ← Command(入口)
    │ "Invokes the planner agent"
    ▼
agents/planner.md                  ← Agent(执行者)
    │ model: opus             ← 注意:用最强模型
    │ tools: Read, Grep, Glob ← 注意:只读不写
    │ "Requirements → Architecture → Steps → Order"
    ▼
rules/common/development-workflow.md ← Rule(流程约束)
    │ "Research & Reuse → Plan → TDD → Review → Commit"
    ▼
输出:结构化实施计划(等待用户确认后才执行)

关键观察

  • Planner 的工具只有 Read/Grep/Glob — 只读不写,这是 Plan Before Execute 原则的落地
  • 使用 Opus 模型 — 规划需要最强的推理能力
  • 必须等待用户确认 — "WAIT for user CONFIRM before touching any code"

3.3 调用链三:/code-review

用户输入:/code-review

    │
    ▼
commands/code-review.md            ← Command(入口)
    │ 判断模式:PR 模式 or 本地模式
    ▼
agents/code-reviewer.md            ← Agent(执行者)
    │ model: sonnet
    │ tools: Read, Grep, Glob, Bash
    │ "Gather → Understand → Read → Apply → Report"
    ▼
rules/common/security.md           ← Rule(安全检查清单)
    │ "8 项 CRITICAL 安全检查"
    ▼
rules/common/coding-style.md       ← Rule(代码质量清单)
    │ "7 项质量检查"
    ▼
输出:分级审查报告(CRITICAL / HIGH / MEDIUM / LOW)

关键观察

  • Code-reviewer 有 Bash 工具 — 它可以运行 git diff 获取变更
  • 审查结果分级,有明确的 Approve / Warning / Block 标准
  • 置信度过滤:只报告 >80% 确信是真实问题的发现

四、命令分类速览表(79 个)

79 个命令看起来很多,但按类别分组后结构清晰:

4.1 核心工作流(必须掌握)

命令 作用 关联 Agent
/plan 实施规划 planner
/tdd 测试驱动开发 tdd-guide
/code-review 代码审查 code-reviewer
/verify 验证循环 (Skill 驱动)
/e2e E2E 测试 e2e-runner
/build-fix 修复构建 build-error-resolver
/feature-dev 完整功能开发 多 Agent 协作

4.2 语言审查

命令 语言
/go-review/go-build/go-test Go
/rust-review/rust-build/rust-test Rust
/python-review Python
/cpp-review/cpp-build/cpp-test C++
/kotlin-review/kotlin-build/kotlin-test Kotlin
/flutter-review/flutter-build/flutter-test Flutter/Dart

4.3 多代理编排

命令 作用
/orchestrate 级联执行(dmux-workflows + autonomous-agent-harness)
/multi-plan 多模型协作规划(Codex/Gemini 双模型分析)
/multi-execute 多模型协作执行(原型 → 重构 → 审计)
/multi-backend/multi-frontend 前后端分离开发
/multi-workflow 多工作流编排
/devfleet 开发舰队模式

4.4 会话管理

命令 作用
/save-session 保存当前会话
/resume-session 恢复历史会话
/sessions 查看会话列表
/checkpoint 创建检查点
/context-budget 上下文预算管理

4.5 学习与进化

命令 作用
/learn 从会话中提取模式
/learn-eval 评估学习效果
/skill-create 从 Git 历史生成 Skill
/evolve 自动进化 Skill
/instinct-export/instinct-import/instinct-status 直觉系统

4.6 工具与辅助

命令 作用
/refactor-clean 死代码清理
/update-docs 更新文档
/update-codemaps 更新代码地图
/quality-gate 质量门禁
/prune 清理无用文件
/harness-audit 审计 Harness 配置
/prompt-optimize 优化 Prompt

五、必记 5 命令

如果你只能记住 5 个命令,记这 5 个。它们覆盖了完整的开发循环:

/plan          → 开始前先规划(Plan Before Execute)
    │
    ▼
/tdd           → 按 RED-GREEN-IMPROVE 写代码(Test-Driven)
    │
    ▼
/code-review   → 写完后审查(Agent-First)
    │
    ▼
/verify        → 审查后验证(测试 + Lint + 类型 + 安全)
    │
    ▼
/learn         → 提交后学习(持续改进)

这 5 个命令恰好对应了五大原则的落地:

命令 对应原则
/plan Plan Before Execute
/tdd Test-Driven
/code-review Agent-First + Security-First
/verify Security-First + Immutability
/learn 持续改进(闭环)

六、调用链通用模式

观察三条调用链,可以总结出一个通用模式:

用户输入 /command
    │
    ▼
commands/xxx.md          ← 入口路由(Legacy Shim)
    │
    ▼
skills/xxx/SKILL.md      ← 工作流知识(可选,有些命令直接到 Agent)
    │
    ▼
agents/xxx.md            ← 执行者(模型 + 工具 + 角色定义)
    │
    ▼
rules/common/*.md        ← 约束条件(安全、编码风格、测试要求)
    │
    ▼
输出结果

不是每条链都经过所有节点。简单命令可能跳过 Skill 直接到 Agent,甚至直接执行。但核心工作流命令(/tdd、/plan、/code-review)通常是完整链路。


七、本课练习

练习 1:追踪调用链(20 分钟)

从以下 5 个不同类别的命令中各选一个,追踪其完整调用链:

  1. 核心工作流/e2e
  2. 语言审查/go-review
  3. 多代理/orchestrate
  4. 会话管理/save-session
  5. 学习/learn

对每个命令:

  • 打开 commands/xxx.md,找到它引用的 Skill 或 Agent
  • 打开对应的 Skill/Agent,找到它引用的 Rule
  • 画出完整链路图
# 提示:用这个命令快速查看 Command 的内容
cat commands/e2e.md | head -20

练习 2:绘制流程图(15 分钟)

用文本或绘图工具,画出 /tdd 的完整调用链流程图。要求包含:

  • 每个节点的文件路径
  • 节点之间传递了什么信息
  • 每个节点的模型选择(如果有)

练习 3:分类记忆(10 分钟)

不看本课内容,尝试回忆 79 个命令的 6 大类别名称,并为每个类别写出至少 2 个代表命令。

练习 4(选做):追踪一条新链

选择一个你没见过的命令(比如 /prp-implement/gan-design),完全从零追踪其调用链。记录你遇到的困难和发现。


八、本课小结

你应该记住的 内容
Command 定位 Legacy Slash-Entry Shim,正在向 Skill 迁移
调用链模式 Command → Skill → Agent → Rule → 输出
命令总数 79 个,分 6 大类
必记 5 命令 /plan、/tdd、/code-review、/verify、/learn
Planner 特点 用 Opus 模型,只读工具,等待确认
迁移趋势 工作流逻辑集中到 Skill,Command 只做路由

九、下节预告

第 13 课:TDD 全流程 — RED-GREEN-IMPROVE

下节课我们将完整体验一次 TDD 循环。你会亲手写一个失败的测试、实现最小代码让它通过、然后重构优化。这是 ECC 开发流程中最核心的技能 — 不会 TDD,后面的验证循环和多代理编排都无从谈起。

预习建议:阅读 rules/common/testing.mdagents/tdd-guide.md,理解 AAA 模式(Arrange-Act-Assert)。

第 11 课:Scripts — Hook 的底层实现

作者 王小酱
2026年4月8日 23:11

所属阶段:第二阶段「组件精讲」(第 4-14 课) 前置条件:第 10 课 本课收获:能编写符合规范的 Hook 脚本并编写测试


一、本课概述

上节课我们学习了 Hook 的事件类型和配置格式。本课深入实现层scripts/ 目录。这里存放着所有 Hook 的实际代码。

本课回答三个问题:

  1. scripts 目录怎么组织? — 三个子目录各司其职
  2. Hook 脚本怎么写? — 标准模式、run-with-flags.js 包装器
  3. 怎么测试? — 测试规范和实战

二、目录结构

2.1 整体布局

scripts/
├── lib/                  # 共享库(工具函数)
│   ├── utils.js          # 跨平台工具函数
│   ├── package-manager.js # 包管理器检测
│   ├── hook-flags.js     # Hook 启用/禁用控制
│   ├── session-manager.js # 会话管理
│   ├── resolve-ecc-root.js # 解析 ECC 安装根目录
│   └── ...               # 其他共享模块
│
├── hooks/                # Hook 实现脚本
│   ├── run-with-flags.js # 核心包装器
│   ├── session-start-bootstrap.js
│   ├── pre-bash-commit-quality.js
│   ├── post-edit-console-warn.js
│   ├── stop-format-typecheck.js
│   ├── desktop-notify.js
│   └── ...               # 其他 Hook 脚本(30+ 个)
│
├── ci/                   # CI/CD 验证工具
│   ├── validate-agents.js
│   ├── validate-skills.js
│   ├── validate-hooks.js
│   └── ...               # 其他验证脚本
│
├── ecc.js                # CLI 入口
└── doctor.js             # 环境诊断工具

2.2 三个子目录的职责

目录 职责 被谁调用
lib/ 提供共享的工具函数 hooks/ 和 ci/ 中的脚本
hooks/ Hook 事件的具体实现 hooks.json 中的 command 字段
ci/ CI 流水线中的验证脚本 GitHub Actions

依赖关系

ci/ scripts
    └── require → lib/ (共享函数)

hooks/ scripts
    └── require → lib/ (共享函数)

lib/ 内部
    └── 模块之间也有 require 关系

三、代码约定

3.1 CommonJS Only

ECC 的所有脚本使用 CommonJS 模块系统,不使用 ESM

// 正确:CommonJS
const fs = require('fs');
const path = require('path');
const { getClaudeDir } = require('../lib/utils');

module.exports = { myFunction };

// 错误:不要用 ESM
import fs from 'fs';           // ✗
export default myFunction;      // ✗

原因:Node.js 18+ 虽然支持 ESM,但 CommonJS 在脚本工具中更简单直接,不需要处理 .mjs 扩展名、package.jsontype 字段等复杂性。

3.2 const 优先,禁止 var

// 好
const MAX_STDIN = 1024 * 1024;
const result = processInput(data);
let counter = 0;  // 确实需要重新赋值时用 let

// 差
var MAX_STDIN = 1024 * 1024;  // ✗ 永远不要用 var

3.3 Hook 脚本 <200 行

如果一个 Hook 脚本超过 200 行,说明它做了太多事情。正确做法:

# 差:300 行的 commit-quality.js

# 好:拆分
scripts/hooks/pre-bash-commit-quality.js  (80 行,入口)
scripts/lib/commit-validator.js           (120 行,核心逻辑)
scripts/lib/secret-detector.js            (60 行,密钥检测)

规则:Hook 脚本负责"胶水逻辑"(读 stdin、调用库函数、输出结果),核心逻辑提取到 lib/


四、Hook 脚本标准模式

4.1 通过 run-with-flags.js 运行的模式

大多数 ECC Hook 不直接被 hooks.json 调用,而是通过 run-with-flags.js 包装器运行。

hooks.json 中的调用方式

{
  "command": "node scripts/hooks/run-with-flags.js \"post:edit:console-warn\" \"scripts/hooks/post-edit-console-warn.js\" \"standard,strict\""
}

三个参数

参数 示例 说明
hookId post:edit:console-warn Hook 的唯一标识
scriptPath scripts/hooks/post-edit-console-warn.js 实际脚本的相对路径
profiles standard,strict 在哪些 Profile 下启用

4.2 run-with-flags.js 的作用

hooks.json 调用 run-with-flags.js
    │
    ├── 1. 检查 ECC_HOOK_PROFILE 环境变量
    │      当前 Profile 是否在允许列表中?
    │      不在 → exit 0(跳过)
    │
    ├── 2. 检查 ECC_DISABLED_HOOKS 环境变量
    │      当前 hookId 是否被禁用?
    │      是 → exit 0(跳过)
    │
    ├── 3. 读取 stdin(工具调用的 JSON 数据)
    │
    ├── 4. 加载实际脚本
    │      require(scriptPath)
    │
    ├── 5. 调用 module.exports.run(rawInput)
    │      或者 spawn 子进程执行
    │
    └── 6. 转发 exit code
           脚本的 exit code → run-with-flags.js 的 exit code

关键价值

  1. 统一管理启用/禁用 — 所有 Hook 的 Profile 检查在一处完成
  2. 支持环境变量控制 — 不改 hooks.json 就能调整 Hook 行为
  3. 统一 stdin 解析 — 不需要每个脚本自己解析 JSON

4.3 Hook 脚本的标准写法

'use strict';

function run(rawInput) {
  let input;
  try {
    input = JSON.parse(rawInput);
  } catch (err) {
    process.stderr.write('[HookName] Failed to parse input\n');
    process.exit(0);  // 解析失败不阻塞
  }

  const toolInput = input.tool_input || {};
  const filePath = toolInput.file_path || '';

  if (!filePath.endsWith('.js')) {
    process.exit(0);  // 不需要处理
  }

  try {
    doWork(filePath);
    process.stderr.write(`[HookName] Processed: ${filePath}\n`);
  } catch (err) {
    process.stderr.write(`[HookName] Error: ${err.message}\n`);
  }

  process.exit(0);
}

module.exports = { run };

4.4 关键规则总结

规则 原因
'use strict' 启用严格模式,捕获更多错误
JSON 解析失败 → exit 0 不因输入问题阻塞工具执行
stderr 带 [HookName] 前缀 方便日志排查
所有路径用 exit 0 兜底 防止意外拦截
提取 module.exports.run 让 run-with-flags.js 能加载和调用

五、包管理器检测优先级链

5.1 检测流程

scripts/lib/package-manager.js 实现了一个精心设计的优先级链来检测项目使用的包管理器:

优先级从高到低:

1. 环境变量 CLAUDE_PACKAGE_MANAGER
   │ 用户显式指定,最高优先级
   │
2. 项目配置文件 (.claude/config.json 中的 packageManager)
   │ 项目级别的配置
   │
3. package.json 中的 packageManager 字段
   │ Node.js 官方的 corepack 配置
   │
4. Lock 文件检测
   │ pnpm-lock.yaml → pnpm
   │ bun.lockb → bun
   │ yarn.lock → yarn
   │ package-lock.json → npm
   │
5. 全局配置 (~/.claude/config.json)
   │ 用户全局偏好
   │
6. 默认值:npm

5.2 支持的包管理器

const PACKAGE_MANAGERS = {
  npm:  { lockFile: 'package-lock.json', execCmd: 'npx',      ... },
  pnpm: { lockFile: 'pnpm-lock.yaml',   execCmd: 'pnpm dlx',  ... },
  yarn: { lockFile: 'yarn.lock',         execCmd: 'yarn dlx',  ... },
  bun:  { lockFile: 'bun.lockb',         execCmd: 'bunx',      ... }
};

5.3 Lock 文件检测顺序

Lock 文件的检测顺序是 pnpm → bun → yarn → npm,不是字母顺序。

原因:如果一个项目同时存在多个 lock 文件(这种情况在迁移过程中很常见),应该优先选择更现代的包管理器。


六、共享库 lib/ 详解

6.1 utils.js — 核心工具函数

scripts/lib/utils.js 提供跨平台的工具函数:

函数 作用
getHomeDir() 获取用户主目录(兼容 Windows/macOS/Linux)
getClaudeDir() 获取 ~/.claude 目录路径
getSessionsDir() 获取会话数据目录
readFile(path) 安全的文件读取(不存在返回 null)
writeFile(path, content) 安全的文件写入(自动创建目录)
commandExists(cmd) 检查命令是否存在

跨平台的关键:HOME(macOS/Linux)和 USERPROFILE(Windows)做了统一处理,优先读环境变量,兜底用 os.homedir()

6.2 hook-flags.js — Hook 启用控制

实现了第 10 课讲的 Profile 系统。核心函数 isHookEnabled(hookId, options) 检查两件事:是否被 ECC_DISABLED_HOOKS 显式禁用,以及当前 Profile 是否匹配。

6.3 resolve-ecc-root.js — 解析安装路径

ECC 可能安装在多个位置,此模块按优先级搜索:CLAUDE_PLUGIN_ROOT 环境变量 → ~/.claude/~/.claude/plugins/ecc/ → 市场安装路径 → 缓存安装路径。


七、测试规范

7.1 测试目录结构

测试目录镜像 scripts 目录结构:

tests/
├── run-all.js            # 测试运行器入口
├── lib/
│   ├── utils.test.js     # 对应 scripts/lib/utils.js
│   └── package-manager.test.js  # 对应 scripts/lib/package-manager.js
└── hooks/
    └── hooks.test.js     # Hook 集成测试

7.2 运行测试

# 运行所有测试
node tests/run-all.js

# 运行单个测试文件
node tests/lib/utils.test.js
node tests/lib/package-manager.test.js
node tests/hooks/hooks.test.js

7.3 测试编写规范

ECC 使用 Node.js 内置的 assert 模块,不依赖外部测试框架。每个测试用 try/catch 包裹,成功打印 PASS,失败打印 FAIL 并设置 process.exitCode = 1

const assert = require('assert');
const { getHomeDir } = require('../../scripts/lib/utils');

try {
  const home = getHomeDir();
  assert.ok(typeof home === 'string', 'getHomeDir returns string');
  assert.ok(home.length > 0, 'getHomeDir returns non-empty string');
  console.log('  PASS: getHomeDir');
} catch (err) {
  console.error('  FAIL: getHomeDir -', err.message);
  process.exitCode = 1;
}

7.4 新脚本必须有测试

这是 ECC 的硬性规则:

新增文件位置 测试要求
scripts/lib/xxx.js 必须tests/lib/xxx.test.js 添加测试
scripts/hooks/xxx.js 必须tests/hooks/ 添加至少一个集成测试
scripts/ci/xxx.js 建议有测试,但不强制(CI 脚本本身就是验证工具)

八、本课练习

练习 1:运行测试(5 分钟)

在项目根目录运行测试,确认所有测试通过:

node tests/run-all.js

回答问题:

  • 总共有多少个测试?
  • 有没有失败的测试?
  • 测试输出的格式是什么样的?

练习 2:阅读 run-with-flags.js(15 分钟)

打开 scripts/hooks/run-with-flags.js,回答:它接收几个命令行参数?怎么判断 Hook 是否应该运行?stdin 读取失败时怎么处理?

练习 3:为 utils.js 编写额外测试(20 分钟)

这是本课最重要的练习。

打开 tests/lib/utils.test.js,为 utils.js 中的一个函数编写额外测试用例。

建议测试的边界情况:

// 例如为 getHomeDir 测试边界情况:

// 1. 当 HOME 环境变量为空字符串时
const originalHome = process.env.HOME;
process.env.HOME = '';
const result1 = getHomeDir();
assert.ok(result1.length > 0, 'getHomeDir handles empty HOME');
process.env.HOME = originalHome;

// 2. 当 HOME 环境变量包含空格时
process.env.HOME = '/Users/my user';
const result2 = getHomeDir();
assert.ok(result2.includes('my user'), 'getHomeDir preserves spaces');
process.env.HOME = originalHome;

运行测试验证:

node tests/lib/utils.test.js

练习 4(选做):追踪 Hook 完整链路

选择 pre:bash:git-push-reminder,从 hooks.json 配置 → run-with-flags.js → 实际脚本,画出完整调用链。


九、本课小结

你应该记住的 内容
目录结构 lib/(共享库)、hooks/(Hook 实现)、ci/(CI 验证)
代码约定 CommonJS only、const 优先、Hook 脚本 <200 行
Hook 标准模式 module.exports.run = function(rawInput) {...}
run-with-flags.js 统一管理 Profile 检查、禁用检查、stdin 解析
包管理器检测 环境变量 → 项目配置 → package.json → lock 文件 → 全局配置 → npm
测试规范 tests/ 镜像 scripts/ 结构,新脚本必须有测试

十、下节预告

第 12 课:Commands — 用户交互入口

下节课我们进入 Commands 组件。Commands 是用户与 ECC 交互的最直接方式 — 输入 /tdd 就启动 TDD 工作流,输入 /plan 就开始规划。你将了解 79 个命令的分类、命令与 Agent 的映射关系,以及如何创建自定义命令。

预习建议:在 Claude Code 中输入 / 看看有哪些可用命令。打开 commands/ 目录浏览几个命令文件的格式。

不到 24 小时,奥特曼的天塌了两次

作者 姚桐
2026年4月8日 11:43

Sam Altman 估计又要失眠了。

早上,《纽约客》刚发一篇万字调查报道来指责自己是「反社会骗子」,转头 OpenAI 的年化营收就被自己最大的竞争对手 Anthropic 反超了。

2024 年初,Anthropic 的年化营收还只有 10 亿美元。十六个月后,这个数字变成了 300 亿,超过了 OpenAI 的 250 亿

值得注意的是,年化营收(ARR)是一种推算,不是已经装进口袋的真金白银。Anthropic 的算法是把最近四周的 API 营收乘以 13,订阅收入乘以 12,加总得出。OpenAI 的计算方式与此类似,用四周总收入乘以 13。口径相对一致,但也意味着一旦某个月需求骤然爆发,数字就会被放大,反之亦然。

数字背后,还藏着两种完全不同的商业逻辑。

一个五天原型,25 亿美元的生意

Anthropic 的营收里,70% 到 75% 来自企业和开发者的 API 消耗。客户把 Claude 嵌进自家产品和工作流,用多少付多少。剩下的来自 Claude Pro、Claude Max 等消费端订阅,以及 Claude Code 的企业合同。

Claude Code 值得单独说一下。

2024 年 9 月,Anthropic 内部一位 TypeScript 工程师写了个 Apple Script 提升自己的效率,五天之内半个工程团队都在用。这个意外的原型后来变成了 Claude Code,一个在终端里运行的智能编程代理,能读懂代码库,规划操作步骤,自主执行编辑、测试、提交。

目前,Claude Code 的年化营收已经达到 25 亿美元。全球 GitHub 公开代码提交中有 4% 是由它生成的,这个数字在一个月内翻了一番,预计年底将达到 20%。届时全球每五条代码提交,就有一条出自同一个模型之手。
就是这样一个五天搓出来的原型,变成了 25 亿美元的生意。

直接去找愿意付钱的人

OpenAI 拥有 9 亿周活跃用户,ChatGPT 是人类历史上增长最快的消费级应用之一。

但这 9 亿用户中,只有大约 5% 到 6% 是付费的,其余 94% 免费使用。

此前我们写过一篇文章,指出了 OpenAI 为了维持 ChatGPT 这个「大体上免费」的产品,需要付出极高的算力成本,相当于是在做「补贴」。(考虑到 OpenAI 此前宣布在免费档上加入广告,无疑是因为在 7-8 亿周活用户的量级上做算力补贴的成本实在太难以接受。)

据 The Information 报道,OpenAI 预计 2026 年将亏损 140 亿美元,累计亏损到 2028 年底将达到 440 亿,最早也要 2029 年才能盈利——甚至,就连 ChatGPT Pro 订阅都是亏钱的,奥特曼自己也承认了这一点。

去年,汇丰银行环球投资研究对 OpenAI 的收入模型做了分析,指出:OpenAI 需要在 2030 年实现至少 30 亿周活跃用户,并且其中付费用户的比例达到 10%,才能够避免「入不敷出」。

和现在相比,这个周活跃用户只需要再翻两倍多一点;但是,付费用户数量却需要增长 6.5 倍才行

Anthropic 走的是另一条路。

它大约 80% 的收入来自企业客户。两年前有 12 家公司每年向 Anthropic 支付超过 100 万美元,现在这个数字超过了 1000 家,而且在不到两个月内就从 500 家翻了一番。八家「财富」前十强企业都是它的客户。

Anthropic 每位月活跃用户平均收入为 211 美元,OpenAI 每位周活跃用户平均收入为 25 美元。虽然口径不一,但即便统一口径计算,A 社的变现能力都比 OpenAI 要强得多。

今年 3 月,首次购买 AI 工具的企业中,有 73% 选择了 Anthropic。十周前这个比例还是五五开,去年 12 月甚至是 60:40 偏向 OpenAI。Axios 在报道中指出,AI 竞赛的焦点正在从「谁的模型最好」转向「谁能最快变现」,而 Anthropic 正在企业客户这个最重要的战场上拉开距离。

消费互联网的流量思维和企业软件的价值思维之间,存在一种根本性的差异:OpenAI 选择了前者,用免费产品圈住数亿用户,再想办法转化。Anthropic 选择了后者,直接去找愿意付钱的人。

在 AI 模型的推理成本高居不下的今天,后者看起来是更健康的路径。但这并不意味着 OpenAI 做错了。9 亿用户这个数字还是令人不可小觑的,只是,OpenAI 这个用户体量(特别是前面提到的付费比例)想要兑现为真实收入,周期要比企业软件路线更长、风险更大。

可能这也是为什么 OpenAI 正在考虑收缩它的消费级产品,将重心转向企业市场。

只是,这可能又落入了我们今天在前一篇文章里提到的陷阱:在 AI 事业的关键议题上,OpenAI 经常摇摆不定,会有重视-忽略-重视-忽略的循环。

谁也没法说,OpenAI 今天看重企业市场,回头过两年会不会又改主意。

(成天改主意,每次都 all in,这味道倒是像极了某公司……)

而且,转身需要时间,而 Anthropic 从一开始就已经站在终点线上。

300 亿美元的营收需要相应的基础设施来支撑,Anthropic 今天宣布与谷歌、博通的三方协议,就是为此而来。

根据提交到了美国证券交易委员会的文件,博通将承担更多谷歌 TPU 的代工业务,而从 2027 年起 Anthropic 将通过该公司获得大约 3.5 吉瓦的 TPU 算力。

瑞穗分析师估算,在 2026 年,博通仅从 Anthropic 一家就将获得 210 亿美元的 AI 收入,2027 年达到 420 亿。

Anthropic 的算力策略也值得注意。它同时使用 AWS 的 Trainium、Google 的 TPU 和 NVIDIA 的 GPU 三种芯片平台,同时也是唯一一家在 AWS Bedrock、Google Cloud Vertex AI 和 Microsoft Azure Foundry 三大云平台上都提供前沿模型的 AI 公司。

这种多平台策略,让企业客户此前无论在哪个云平台上,都可以无需更换平台即可接入 Claude 大模型 API,同时更让 Anthropic 避免了对单一供应商的依赖

二级市场已经开始重新定价

买方对 Anthropic 股票的需求目前高达 20 亿美元,几乎找不到愿意出手的卖家。隐含估值从两个月前 G 轮融资时的 3800 亿美元上升到了约 6000 亿美元。高盛对 Anthropic 配售收取 15% 到 20% 的业绩报酬。

与此同时,价值 6 亿美元的 OpenAI 股票据说无人问津。

IPO 的话题正在变得越来越具体。据 The Information 报道,包括 CEO Dario Amodei 在内的 Anthropic 高管已经在讨论最早于 2026 年 10 月上市,公司聘请了 Wilson Sonsini 作为法律顾问,并与高盛、摩根大通组成的银行团队推进 S-1 文件的准备。

承销方预计此次募资将超过 600 亿美元,若成真,将成为科技史上仅次于 SpaceX 的第二大科技 IPO。目前的目标估值从最初的 5000 亿美元起步,市场预期最终可能突破 8000 亿美元。

华尔街日报在两家公司预计今年晚些时候上市前,获取了 OpenAI 和 Anthropic 的机密财务资料。在这场竞赛里,两家公司都在以一种惊人的速度烧钱,只是 Anthropic 的账面比率看起来稍微好看一些。

OpenAI 预计到 2028 年在算力上的支出将达到 1210 亿美元,尽管收入几乎翻了一番,但仅那一年就会亏损 850 亿美元。

剔除训练成本,两家公司现在都接近盈利;把训练成本加回去,OpenAI 的盈亏平衡目标则推到了 2030 年。Anthropic 预计会更早达到,目前其规划 2027 年实现正向自由现金流。

▲ 图片来自:WSJ

增长放缓几乎是不可避免的。Epoch AI 在建模时也注意到,Anthropic 的增速从 2025 年 7 月起已经从每年 10 倍降到了每年 7 倍左右。这依然是一个惊人的数字,但趋势已经在发生变化。

更大的体量意味着每一个百分点的增长都需要绝对量上更大的增量,市场会在某个时点开始出现饱和,竞争也在加剧。

两种 Token 烧法,要解决同一个问题

前文提到,OpenAI 是先圈用户,再想办法变现。这是消费互联网的经典路径,Facebook、Google、TikTok 都是这么走过来的。风险在于,AI 模型的推理成本远高于传统互联网产品,免费用户不是资产,你需要在烧光钱之前找到转化路径。

而 Anthropic 直接去找愿意付钱的人。这是企业软件的经典路径,Salesforce、Oracle、SAP 都是这么走过来的。这里的风险在于,企业市场的天花板比消费市场低得多,而且一旦增长放缓,估值就会被重新定价。

OpenAI 赌的是时间,赌推理成本会快速下降,赌 9 亿用户中总有一部分会转化为付费用户。Anthropic 赌的是确定性,赌企业客户的付费意愿足够强,赌自己能在增长放缓之前建立起足够深的护城河。

现在的问题是,谁的时间窗口会先关闭。

OpenAI 的时间窗口是推理成本下降的速度。如果成本下降得不够快,免费用户就会变成一个无底洞。Anthropic 的时间窗口是企业市场的饱和速度。如果增长放缓得太快,二级市场就会开始重新定价。

两家公司都在和时间赛跑,只是跑道不同。一个在消费市场的长跑道上狂奔,一个在企业市场的短跑道上冲刺。谁会先撞线,谁会先撞墙,现在还不知道。

但有一点是确定的:AI 行业的竞争,已经从「谁的模型最好」变成了「谁能活到最后」。而活到最后的前提,是你得先找到一条能养活自己的路。

Anthropic 找到了,OpenAI 还在找。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

从 0 到 1:用 Node 打通 OpenClaw WebSocket 通信全流程

作者 墨渊君
2026年4月7日 10:29

引言

书接上回, 我们在 OpenClaw 上手实践: 使用 Docker 从构建到可用全流程指南 介绍了, 如果通过 Docker 来快速部署 OpenClaw

其实呢, 这边想要借助 OpenClaw昆仑虚 搭一个个人的 AI 应用, 这里希望整体架构如下:

image

这边 Node 服务端就是做了中间层的转发, 但是这么做有什么好处呢?

  • 权限: 可以进行很好的权限管理, OpenClaw 仅运行 Node 服务进行访问, 不对外开放
  • 多用户: 可以将 sessionagentmessage 等内容按用户进行隔离, 甚至可以一个用户分配一个独立隔离的 OpenClaw(容器)
  • 定制化: 要想做应用, 必然会有很对定制信息, 比如设置 Agent 的头像等。这边我们只需要 OpenClaw 调度大模型的能力, 其他的就希望完全定制。

所以接下来最重要的就是, 在 Node 服务端要如何和 OpenClaw 进行协作(通信), 这也正是接下来我们要聊的....

一、OpenClaw 架构

如图, 是 OpenClaw 的整体架构

image

1.1 智能体运行时环境

这里是整个核心, 是真正干活的核心引擎, 也是我想要的核心能力, 这边主要就是:

  1. 负责拼装 promptcontext
  2. 调度各种大模型
  3. 协调各种 AgentSkillTools 的执行
  4. 保存各种配置、回话记录

当然这边其实没这么简单, 只是想说明这边主要就是核心干活的地方

1.2 网关层

外界各个应用、服务、IM 如何通知引擎部分让 Agent 开始干活? 而引擎部分又如何告知外界 Agent 处理的结果? 而它们之间又是怎么鉴权的? 怎么通信的? 这都是网关层进行控制的。

image

OpenClaw 通过 WebSocket 并定义了一套协议, 来链接 "外界" 和 "引擎"

如下所示, 是外界通过 ws 连接到 OpenClaw 网关, 并约定好的参数(协议)来调用 "引擎" 干活:

import WebSocket from 'ws';

const ws = new WebSocket('ws://127.0.0.1:18789');

ws.send(JSON.stringify({
  type: 'req',  // 请求类型,固定为 req
  id: '任意唯一ID', // 请求 ID
  method: 'chat.send', // 请求内容
  params: {}, // 请求参数,根据不同 method 定义不同的参数结构
}));

同时, 外界也是通过 ws 来监听网关发来的消息, 来获取 "引擎" 广播的消息:

// 监听 OpenClaw 广播的消息
ws.on('message', (data) => {
  
});

// 可能数据如下
{
  "type": "event",
  "event": "chat",
  "payload": {
    "runId": "同一个 runId",
    "sessionKey": "main",
    "seq": 1,
    "state": "delta",
    "message": {
      "role": "assistant",
      "content": [
        { "type": "text", "text": "正在生成中的文本" }
      ],
      "timestamp": 1710000000000
    }
  }
}

我们可能习惯性通过 REST API 来调用第三方服务提供的接口来获取数据、修改数据, 但这边则全部走 WebSocket 并通过约定好的协议来完成所有事情

// 拉历史
ws.send(JSON.stringify({
  type: "req",
  id: "history-1", // 自定义请求 AI
  method: "chat.history", // 具体请求方法
  params: {} // 参数
}));

// 拉 agent 列表
ws.send(JSON.stringify({
  type: "req",
  id: "agents-1", // 自定义请求 AI
  method: "agents.list", // 具体请求方法
  params: {} // 参数
}));

1.3 其他层

  1. 工具与能力层: 本质上大模型是不具备各种调用工具的能力的, 所有工具的调用都是在本地完成, 并将调用结果告诉大模型。大模型再进行决策, 而这边工具与能力层就是提供各种工具能力, 来供 OpenClaw 来调度, 在需要时 OpenClaw 会调用相关工具来完成各类工作, 并将工具调用结果返回给大模型

  2. 接口控制层、消息通讯渠道: 这边其实就是针对各种场景、IM, 来做一些兼容处理, 使得能够顺利接入网关。

二、握手流程

参考文档: Gateway 网关协议 - 握手

如下图所示:

  1. 当客户端与 OpenClaw 网关连接建立后
  2. 网关会立刻发送 connect.challenge 事件(消息)
  3. 客户端需要紧接着发送 connect 请求(含鉴权信息)
  4. 网关层鉴权成功则返回 hello-ok 响应, 否则则关闭连接

image

如下代码所示, 是一个最简化的 DEMO:

import WebSocket from 'ws';
// 1. 建立连接
const ws = new WebSocket('ws://127.0.0.1:18789'); 

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());

  // 2. 网关发送 connect.challenge 事件(消息)
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
    console.log('🔐 receive challenge');

    // 3. 客户端紧接着发送 connect 请求(含鉴权信息)
    ws.send(JSON.stringify({
      id: '1', // 唯一 ID 客户端自己随便写即可
      type: 'req', 
      method: 'connect',
      params: {
        minProtocol: 3,
        maxProtocol: 3,
        client: {
          id: 'cli', 
          version: '1.0.0',
          platform: 'node',
          mode: 'node',
        },
        role: 'operator',
        scopes: [
          'operator.read',
          'operator.write',
          'operator.admin',
          'operator.approvals',
          'operator.pairing',
        ],
        auth: { token: '9e1a21f5555asdsads555666666666df3f81' }, // 换成你自己的 OpenClaw 登陆 Token
      },
    }));
  }

  // 4. 网关层鉴权成功则返回 hello-ok 响应
  if msg.payload?.type === 'hello-ok') {
    console.log('🎉 connected success');
  }
});

// 其他事件
ws.on('open', () => console.log('✅ connected'));
ws.on('close', () => console.log('✅ connected'));

使用 Node 运行结果如下:

image

三、简单通信

上面我们简单演示了和 OpenClaw 网关建立握手连接, 但是实际上还缺了设备鉴权、授权这部分内容, 如果想要调用一些操作就需要把这部分补全...

3.1 设备身份鉴权

这边其实就是:

  • 根据 OpenClaw 自己的一套加密方式, 在客户端生成唯一设备 ID、公钥、私钥
  • 在握手阶段认证阶段, 需要按 OpenClaw 定义的规则, 生成相关的签名、设备信息, 一同传给网关层
  • 并且在首次设备连接时, 需要在 OpenClaw 进行设备的授权
  • 需要注意的是: 我们生成的设备 ID、公钥、私钥, 应该是固定不变的, 不应该每次都动态生成(实际场景中, 我们需要进行缓存, 或者加到服务配置中)

下面是一份完整的设备信息、签名生成代码:

import crypto from 'node:crypto';
import fs from 'fs';

const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');

// base64url 编码
const base64UrlEncode = (buf) => buf.toString('base64').replaceAll('+', '-')
  .replaceAll('/', '_')
  .replace(/=+$/g, '');

// 从 PEM 格式的公钥中提取原始公钥数据,并进行 base64url 编码
const derivePublicKeyRaw = (publicKeyPem) => {
  const key = crypto.createPublicKey(publicKeyPem);
  const spki = key.export({ type: 'spki', format: 'der' });

  if (
    spki.length === ED25519_SPKI_PREFIX.length + 32 &&
    spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
  ) {
    return base64UrlEncode(spki.subarray(ED25519_SPKI_PREFIX.length));
  }

  return base64UrlEncode(spki);
};

// 从原始公钥数据派生设备 ID,通常是公钥的 SHA-256 哈希值
const deriveDeviceIdFromPublicKey = (publicKeyRawBase64Url) => crypto
  .createHash('sha256')
  .update(Buffer.from(publicKeyRawBase64Url, 'base64url'))
  .digest('hex');

// 创建网关设备身份,包括生成密钥对和设备 ID
const createGatewayDeviceIdentity = () => {
  // 如果已经存在设备身份文件,则直接读取并返回
  if (fs.existsSync('./device_identity.json')) {
    const content = fs.readFileSync('./device_identity.json', 'utf-8');
    return JSON.parse(content);
  }

  const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');

  const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
  const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
  const publicKeyRaw = derivePublicKeyRaw(publicKeyPem);
  const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);

  const identity = {
    deviceId,
    privateKeyPem,
    publicKeyPem,
    publicKeyRaw,
  };

  // 将生成的设备身份信息保存到文件中,供后续使用
  fs.writeFileSync('./device_identity.json', JSON.stringify(identity, null, 2), 'utf-8');

  return identity;
};

// 构建设备认证信息,包括生成签名等
export const buildDeviceAuthPayloadV3 = (params) => [
  'v3',
  params.deviceId,
  params.clientId,
  params.clientMode,
  params.role,
  params.scopes.join(','),
  String(params.signedAtMs),
  params.token ?? '',
  params.nonce,
  params.platform ?? '',
  params.deviceFamily ?? '',
].join('|');

// 使用设备的私钥对认证负载进行签名,生成 base64url 编码的签名字符串
export const signDevicePayload = (privateKeyPem, payload) => crypto.sign(null, Buffer.from(payload, 'utf8'), privateKeyPem).toString('base64url');

// 构建网关设备认证信息,供连接网关时使用
export const buildGatewayDeviceAuth = (params) => {
  const signedAt = Date.now();
  const identity = createGatewayDeviceIdentity();

  const payload = buildDeviceAuthPayloadV3({
    deviceId: identity.deviceId,
    clientId: params.clientId,
    clientMode: params.clientMode,
    role: params.role,
    scopes: params.scopes,
    signedAtMs: signedAt,
    token: params.token,
    nonce: params.nonce,
    platform: params.platform,
    deviceFamily: params.deviceFamily,
  });

  const signature = signDevicePayload(identity.privateKeyPem, payload);

  return {
    signedAt,
    signature,
    nonce: params.nonce,
    id: identity.deviceId,
    publicKey: identity.publicKeyRaw,
  };
};

下面是完整连接 OpenClaw 网关代码, 这边调用 buildGatewayDeviceAuth 来生成设备签名等信息:

import WebSocket from 'ws';
import { buildGatewayDeviceAuth } from './device.mjs';

const REQUESTED_SCOPES = ['operator.admin'];
const CLIENT = {
  id: 'cli',
  version: '1.0.0',
  platform: 'node',
  mode: 'node',
  deviceFamily: 'desktop',
};

const GATEWAY_TOKEN = 'your-real-token';
const ws = new WebSocket('ws://127.0.0.1:18789');

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());

  // 1️⃣ 先接 challenge
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
    console.log('🔐 receive challenge', msg.payload);
    const device = buildGatewayDeviceAuth({
      role: 'operator',
      nonce: msg.payload?.nonce ?? '',
      token: GATEWAY_TOKEN,
      clientId: CLIENT.id,
      clientMode: CLIENT.mode,
      scopes: REQUESTED_SCOPES,
      platform: CLIENT.platform,
      deviceFamily: CLIENT.deviceFamily,
    });

    ws.send(JSON.stringify({
      type: 'req',
      id: '1',
      method: 'connect',
      params: {
        device,
        minProtocol: 3,
        maxProtocol: 3,
        client: CLIENT,
        role: 'operator',
        scopes: REQUESTED_SCOPES,
        auth: { token: GATEWAY_TOKEN },
      },
    }));
  }

  // 2️⃣ connect 成功
  if (msg.payload?.type === 'hello-ok') {
    console.log('🎉 connected success');
  }

  console.log('👀 receive message', msg);
});


// 其他事件
ws.on('open', () => console.log('✅ connected'));
ws.on('close', () => console.log('✅ connected'));

执行上面连接 OpenClaw 脚本, 连接能够成功, 同时还会提示需要配对:

image

进入 OpenClaw 容器内部, 进行设备授权:

docker exec -it openclaw bash # 进入 openclaw 容器
openclaw devices list # 查看当前设备连接情况
openclaw devices approve b5950461-e541-4114-9165-413fb3e7afe2 # 授权设备 b5950461-e541-4114-9165-413fb3e7afe2

image

3.1 发起对话

如下代码所示:

  • 在连接 OpenClaw 网关成功之后, 1秒 后我们立马发起一轮对话
  • 发送对话本质上其实就是调用 webSocket.send 方法, 并定义合适的 methodparams 等参数
  • 最后我们再通过监听 message 类型, 来获取大模型输出内容
// connect 成功
if (msg.payload?.type === 'hello-ok') {
  console.log('🎉 connected success');

  // 发 chat
  setTimeout(() => {
    ws.send(JSON.stringify({
      type: 'req',
      id: Math.random().toString(16),
      method: 'chat.send',
      params: {
        sessionKey: 'agent:main:main',
        message: '你好,世界!',
        idempotencyKey: Math.random().toString(16), // 确保消息幂等, 避免重复发送
      },
    }));
  }, 1000);
}

// 接收消息
if (msg.type === 'event' && msg.event === 'agent') {
  console.log('💬 receive message', msg.payload);
}

最终执行代码结果如下:

image

3.2 查询可用模型列表

开始前我们写一个通用的工具函数 sendRpc, 在 OpenClaw 都是同 webSocket 来发起各种请求, 那么要如何去监听到每次请求的响应呢? 如下代码所示, 其实我们在使用 send 来模拟发起一个请求时会给一个唯一的请求 ID, OpenClaw 处理完请求后, 将响应接口加请求 ID 一起推送给我们, 通过该唯一请求 ID 我们就可以精准获取到我们需要的响应结果。

const sendRpc  = (ws, method, params = {}) => {
  // 每次发送请求都生成一个唯一的 ID,方便后续匹配响应
  const id = crypto.randomUUID();
  console.log('📤 send request', { id, method, params });

  ws.send(
    JSON.stringify({
      type: 'req',
      id,
      method,
      params,
    }),
  );

  const handleResponse = (data) => {
    const msg = JSON.parse(data.toString());

    // 匹配指定请求 ID 的响应
    if (msg.type === 'res' && msg.id === id) {
      console.log('📩 receive response', JSON.stringify(msg, null, 4));
      ws.off('message', handleResponse); // 收到对应 ID 的响应后取消监听
    }
  };

  ws.on('message', handleResponse); // 监听响应消息, 收到对应 ID 的响应后会取消监听
};

上面方法调用也很简单,

sendRpc(ws, 'models.list', {});

如果需要我们也可以将工具函数改为 Promise 形式

const sendRpc  = (ws, method, params = {}) => new Promise((resolve) => {
  // 每次发送请求都生成一个唯一的 ID,方便后续匹配响应
  const id = crypto.randomUUID();
  console.log('📤 send request', { id, method, params });

  ws.send(
    JSON.stringify({
      type: 'req',
      id,
      method,
      params,
    }),
  );

  const handleResponse = (data) => {
    const msg = JSON.parse(data.toString());

    // 匹配指定请求 ID 的响应
    if (msg.type === 'res' && msg.id === id) {
      console.log('📩 receive response', JSON.stringify(msg, null, 4));
      ws.off('message', handleResponse); // 收到对应 ID 的响应后取消监听
      resolve(msg); // 将响应结果通过 Promise 返回
    }
  };

  ws.on('message', handleResponse); // 监听响应消息, 收到对应 ID 的响应后会取消监听
});

这样就可以使用 await 来等待每次请求响应结果:

await sendRpc(ws, 'models.list', {});

最后在上文的 Demo 基础上, 在连接 OpenClaw 网关后 1秒 尝试调用 sendRpc 来查询下当前可用模型列表:

// connect 成功
if (msg.payload?.type === 'hello-ok') {
  console.log('🎉 connected success');

  // 发 chat
  setTimeout(() => {
    sendRpc(ws, 'models.list', {});
  }, 1000);
}

最后执行结果:

node demo/index.mjs
✅ connected
🔐 receive challenge { nonce: '7f083827-7e61-4b97-84d5-7c075fd191b2', ts: 1775445486797 }
🎉 connected success
📩 receive response {
    "type": "res",
    "id": "b829d02e-fcfb-45ee-b70c-25e2808bed29",
    "ok": true,
    "payload": {
        "models": [
            {
                "id": "gpt-5.1",
                "name": "GPT-5.1",
                "provider": "openai-codex",
                "contextWindow": 272000,
                "reasoning": true,
                "input": [
                    "text",
                    "image"
                ]
            }
        ]
    }
}

四、OpenClaw 所有协议

所有 WebSocket 消息定义在 src/gateway/protocol/schema/frames.ts 中, 总的来说有三个大类:

类型 type 用途
Request "req" 客户端发起请求(含 id, method, params)
Response "res" 服务端对请求的响应(含 id, ok, payload/error)
Event "event" 服务端主动推送事件(含 event, payload, seq)

4.1 常见 RPC 方法

OpenClaw 通过 WebSocketextensions/whatsapp/src/shared.ts 实现了 100 多个可用的 RPC 方法:

# 方法名 分类 说明
1 health 系统 获取网关健康状态
2 doctor.memory.status 系统 内存诊断状态
3 logs.tail 系统 获取日志尾部
4 channels.status 频道 获取所有频道状态
5 channels.logout 频道 登出频道
6 status 系统 获取完整网关状态
7 usage.status 用量 获取使用状态
8 usage.cost 用量 获取使用费用
9 tts.status TTS TTS 状态
10 tts.providers TTS 列出 TTS 提供商
11 tts.enable TTS 启用 TTS
12 tts.disable TTS 禁用 TTS
13 tts.convert TTS 文本转语音
14 tts.setProvider TTS 设置 TTS 提供商
15 config.get 配置 获取配置
16 config.set 配置 设置配置
17 config.apply 配置 应用配置
18 config.patch 配置 补丁更新配置
19 config.schema 配置 获取配置 Schema
20 config.schema.lookup 配置 查找配置 Schema
21 exec.approvals.get 执行批准 获取执行批准列表
22 exec.approvals.set 执行批准 设置执行批准列表
23 exec.approvals.node.get 执行批准 获取节点执行批准
24 exec.approvals.node.set 执行批准 设置节点执行批准
25 exec.approval.request 执行批准 请求执行批准
26 exec.approval.waitDecision 执行批准 等待批准决定
27 exec.approval.resolve 执行批准 解决执行批准
28 plugin.approval.request 插件批准 请求插件批准
29 plugin.approval.waitDecision 插件批准 等待插件批准决定
30 plugin.approval.resolve 插件批准 解决插件批准
31 wizard.start 向导 启动配置向导
32 wizard.next 向导 向导下一步
33 wizard.cancel 向导 取消向导
34 wizard.status 向导 获取向导状态
35 talk.config Talk 获取 Talk 配置
36 talk.speak Talk Talk 说话
37 talk.mode Talk 设置 Talk 模式
38 models.list 模型 列出可用模型
39 tools.catalog 工具 获取工具目录
40 tools.effective 工具 获取有效工具
41 agents.list 代理 列出代理
42 agents.create 代理 创建代理
43 agents.update 代理 更新代理
44 agents.delete 代理 删除代理
45 agents.files.list 代理 列出代理文件
46 agents.files.get 代理 获取代理文件
47 agents.files.set 代理 设置代理文件
48 skills.status 技能 获取技能状态
49 skills.bins 技能 获取技能二进制
50 skills.install 技能 安装技能
51 skills.update 技能 更新技能
52 update.run 更新 运行网关更新
53 voicewake.get 语音唤醒 获取唤醒配置
54 voicewake.set 语音唤醒 设置唤醒配置
55 secrets.reload 密钥 重新加载密钥
56 secrets.resolve 密钥 解析密钥引用
57 sessions.list 会话 列出会话
58 sessions.subscribe 会话 订阅会话变化
59 sessions.unsubscribe 会话 取消订阅会话变化
60 sessions.messages.subscribe 会话 订阅会话消息
61 sessions.messages.unsubscribe 会话 取消订阅会话消息
62 sessions.preview 会话 预览会话
63 sessions.create 会话 创建会话
64 sessions.send 会话 发送消息到会话
65 sessions.abort 会话 中止会话
66 sessions.patch 会话 修补会话
67 sessions.reset 会话 重置会话
68 sessions.delete 会话 删除会话
69 sessions.compact 会话 压缩会话
70 last-heartbeat 心跳 获取最后心跳
71 set-heartbeats 心跳 设置心跳
72 wake 系统 唤醒网关
73 node.pair.request 节点配对 请求节点配对
74 node.pair.list 节点配对 列出配对请求
75 node.pair.approve 节点配对 批准配对
76 node.pair.reject 节点配对 拒绝配对
77 node.pair.verify 节点配对 验证配对
78 device.pair.list 设备配对 列出设备配对
79 device.pair.approve 设备配对 批准设备配对
80 device.pair.reject 设备配对 拒绝设备配对
81 device.pair.remove 设备配对 移除设备配对
82 device.token.rotate 设备令牌 轮换设备令牌
83 device.token.revoke 设备令牌 撤销设备令牌
84 node.rename 节点 重命名节点
85 node.list 节点 列出节点
86 node.describe 节点 描述节点信息
87 node.pending.drain 节点队列 排空待处理队列
88 node.pending.enqueue 节点队列 入队待处理工作
89 node.invoke 节点 调用节点命令
90 node.pending.pull 节点队列 拉取待处理工作
91 node.pending.ack 节点队列 确认待处理工作
92 node.invoke.result 节点 节点调用结果
93 node.event 节点 节点事件
94 node.canvas.capability.refresh 节点 刷新画布能力
95 cron.list Cron 列出定时任务
96 cron.status Cron 获取定时任务状态
97 cron.add Cron 添加定时任务
98 cron.update Cron 更新定时任务
99 cron.remove Cron 移除定时任务
100 cron.run Cron 立即运行定时任务
101 cron.runs Cron 获取运行历史
102 gateway.identity.get 网关 获取网关身份
103 system-presence 系统 获取系统存在
104 system-event 系统 系统事件
105 send 消息 发送消息到频道
106 agent 代理 调用代理
107 agent.identity.get 代理 获取代理身份
108 agent.wait 代理 等待代理完成
109 chat.history WebChat 获取聊天历史
110 chat.abort WebChat 中止聊天
111 chat.send WebChat 发送聊天消息
112 web.login.start WhatsApp 启动 Web 登录流程
113 web.login.wait WhatsApp 等待 Web 登录完成

4.2 常见推送事件类型

OpenClaw 通过 WebSocketsrc/gateway/server-broadcast.ts 定义了 20 多个可用的事件推送类型:

事件名 说明 权限范围
connect.challenge 连接握手挑战 -
tick 心跳 (含时间戳) -
heartbeat 保活 -
shutdown 网关关闭 (含 reason) -
health 健康状态更新 -
presence 系统存在更新 -
session.message 会话消息推送 operator.read
session.tool 工具调用事件 operator.read
sessions.changed 会话列表变化 operator.read
chat 聊天流式响应 (含 state: delta/final/aborted/error) -
chat.side_result 聊天副作用结果 -
agent 代理流式输出 -
node.pair.requested 节点配对请求 operator.pairing
node.pair.resolved 节点配对已解决 operator.pairing
node.invoke.request 节点调用请求 -
device.pair.requested 设备配对请求 operator.pairing
device.pair.resolved 设备配对已解决 operator.pairing
exec.approval.requested 执行批准请求 operator.approvals
exec.approval.resolved 执行批准已解决 operator.approvals
plugin.approval.requested 插件批准请求 operator.approvals
plugin.approval.resolved 插件批准已解决 operator.approvals
voicewake.changed 语音唤醒变化 -
talk.mode Talk 模式变化 -
cron Cron 任务事件 -
update.available 更新可用通知 -

五、参考

刚刚,OpenAI 创下史上最大融资纪录,估值逼近万亿

作者 莫崇宇
2026年4月1日 06:32

当所有人还沉浸在 Claude Code 源码泄露事件时,OpenAI 又双叒叕出来抢头条了。就在刚刚,OpenAI 官宣完成一轮 1220 亿美元的融资。

单轮私募 1220 亿,人类商业史上从未有过。融资完成后,OpenAI 的估值落在 8520 亿美元,距离万亿只差一步,而这家公司成立至今才十年。

值得一提的是,这轮融资最初在今年 2 月公布时,承诺金额还是 1100 亿美元,最终收盘时多出了 120 亿,说明后来跟进的机构比预期的多。

外界普遍认为,这是 OpenAI 在年底 IPO 前最后一次大规模私募,上市节奏已经越来越清晰。

钱从哪来的

这轮融资的主要出资方,是亚马逊(500 亿)、英伟达(300 亿)、软银(300 亿),软银还和 a16z、D.E. Shaw 等机构联合领投。

微软作为多年老伙伴继续跟投,但这次没有公开具体金额,只知道截至去年底,微软在 OpenAI 的累计投入已经超过 130 亿美元。

此外,OpenAI 还首次通过银行渠道向富裕个人投资者开放募集,这部分筹到约 30 亿。ARK Invest 旗下规模 60 亿美元的旗舰创新 ETF 也宣布纳入 OpenAI,持仓比例约 3%,这也是该基金首次投资非上市公司。

事实上,T. Rowe Price 和 Fidelity 管理的部分基金早已持有少量 OpenAI 股份,这次 ARK 的加入,进一步打通了普通人参与的渠道。

简言之,几乎整个科技圈都在给 OpenAI 撑场面。

但仔细想想,逻辑其实很简单:OpenAI 拿了这些钱,还是要去买英伟达的芯片,租亚马逊和微软的服务器。巨头们把钱投进来,等于提前锁定了全球最大的算力客户。这轮融资,与其说是看好 OpenAI,不如说是一门稳赚的生意。

而对 OpenAI 来说,这笔钱更像是 IPO 前的最后一次大补仓。

账面数据确实好看:每周活跃用户接近 9 亿,付费用户超过 5000 万,去年全年营收 131 亿美元,单月进账最高 20 亿,而且增速是当年谷歌、Meta 这些互联网巨头同阶段的四倍。

只是,OpenAI 还没盈利,烧钱的速度一点没降下来。

为什么要关掉 Sora

这次融资前后,OpenAI 的产品节奏并没有停滞不前。

他们发布了目前最强的 GPT-5.4,在多任务处理和工作流性能上都有明显提升。代码生成工具 Codex 也从一个功能升级成了独立的编程 Agent,目前每周活跃用户超过 200 万,过去三个月涨了五倍,月增速维持在 70% 左右。

企业端的表现同样值得关注。目前企业服务已经占到 OpenAI 总营收的 40% 以上,预计到 2026 年底会和消费者端打平。

API 每分钟处理的 token 数量超过 150 亿,搜索功能的使用量在过去一年接近翻了三倍,广告试点项目在上线不到六周内年化收入就突破了 1 亿美元。这也是 OpenAI 希望向外界传递的信号,收入来源越来越多元,ChatGPT 的订阅费用只是其中一块了。

然而,就在这一片飘红的数据旁边,Sora 悄悄地下线了。

Sora 刚发布时,确实在影视圈和创意行业引发了不小的震动。一句话生成视频,画面质感还挺真实,很多人觉得这是 AI 技术最让人兴奋的那种东西。

但视频生成的算力消耗,远比文字生成高得多。AI 的每一次推理、每一段文本生成、每一帧视频渲染,都在真实消耗着昂贵的 GPU 计算周期和电能。没有免费的智能,每一次调用都是真金白银的损耗。

而用户这边,虽然觉得好玩,却没多少人愿意为此付高价。

根据华尔街日报》报道,OpenAI 之所以选择关闭 Sora,原因之一也是因为它每天要烧掉约 100 万美元,可用户数量却从上线时的 100 万,暴跌到不足 50 万。

当留存数据难看,商业化路径又模糊不清,这笔烧钱的买卖,自然没有继续下去的理由。于是,现实还没被颠覆,Sora 就已经不存在了。

关掉 Sora 只是开始,OpenAI 还在审视其他花钱多、回报慢的方向,准备进一步收缩;把算力集中到文本模型、代码生成、企业服务这些有稳定现金流的方向,也是 OpenAI 在向华尔街表态:我们知道、也需要怎么赚钱了。

从「改变世界」到「水电煤」

OpenAI 成立于 2015 年,最初的愿景是确保通用人工智能造福全人类。

2019 年,为了筹到足够的研发资金,公司转型为「有限盈利」模式,成立了营利性子公司,接受了微软 10 亿美元的投资。运营主体虽然商业化了,但非营利性的 OpenAI 基金会仍持有约 26% 的股权,名义上延续着最初的公益使命。

OpenAI 融资的官方声明里有一句话值得注意:「构建智能本身的基础设施层」。

寥寥数语,其实道出了 OpenAI 自我定位的转变。以前他们更在意用一个个惊艳的 Demo 刷新外界对 AI 的认知,现在更想做的,是退到幕后,成为企业和个人离不开的底层工具。

他们把这个方向叫做「超级应用」,计划把 ChatGPT、Codex、搜索、浏览器等能力整合进一个统一的入口,主要面向开发者和企业用户,让人不用在一堆工具之间跳来跳去。

这背后的逻辑,是让消费者端的习惯自然带动企业端的采购,两块业务互相强化。

一个普通用户可能今天觉得新鲜、明天就取消订阅,但一家把核心业务跑在 OpenAI 模型上的企业,不太可能说断就断,后者才是华尔街真正想看到的那种客户黏性。

过去几年,AI 行业隔三差五就会出现让人眼前一亮的东西,新模型、新产品、新的可能性,一波接着一波。

但从这轮融资和 Sora 被关掉这件事来看,那个充满惊喜的阶段,可能真的要告一段落了。接下来可能更像是一门成熟的生意:有人管算力、有人管数据、有人管销售,大家各守一块,讲究成本控制,讲究商业落地。

OpenAI 已经回不到从前了,但它也许本来就没打算回去。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

❌
❌