Prompt IDE

来自WHY42

你是怎样调提示词的呢?这看起来是个再简单不过的问题,但对于开发者而言,一直缺乏一个趁手的工具。目前可以用的这些工具大致有以下的问题:

  • 可以同大模型聊天,但是不具备调参能力,例如ChatGPT、文心一言、ChatHub
  • 可以调参,但只支持特定的厂商,例如GPT Playground、Minimax体验中心等
  • 可以调参,也支持不同的厂商,功能也很强大,唯一缺点是不是原生的,如KPP
  • 啥都可以干,但是要写代码,例如LangChain

于是为了解决这个问题,能够更愉快地测试提示词,笔者撸了一个Vs Code插件——Prompt IDE。目前,尽管缺胳膊少腿,Prompt IDE初具雏形。在这个有趣的过程中,学习到了Vs Code插件开发的一些技巧,也对Vs Code的强大有了更深的认知,在这里简单总结一下。

项目起源

这个想法最初源自于公司2023年7月份的黑客马拉松活动,当时,笔者Solo了一个叫做”Prompt Native“的项目。什么是Prompt Native呢?有三层含义[1]:

  • Colud Native: 将大模型的能力做成云原生的FaaS
  • Native Editor: 原生的提示词开发编辑器,也就是今天的Prompt IDE
  • Native Tooling: 通过原生的大模型工具链来辅助我们开发

大致的设计是这样:

  1. 通过一个Vs Code插件来开发提示词,提示词最终存储为一个文件,有这特定的格式。这就是Native Editor
  2. 在开发提示词的过程中,可以通过大模型给出提示词调优的建议,类似与Js Lint,我们可以做一个Prompt Lint,不过当时经过测试发现效果并不好;还有比如自动生成人设,例如chatgpt-prompt-generator-v12通过一个预训练的BART模型自动生成提示词,也可以集成到插件中,做到自动补全。这是Native Tooling
  3. 提供不同语言的FaaS模板和解析运行Prompt文件的库,将其部署到Kubernetes中为云函数。当时采用Knative Functions和Rust实现了一个简单的Hello World模板

得益于GPT辅助编程,作为Rust菜鸟和第一次开发Vs Code插件的半吊子前端,肝几天就做出了一个PoC,看起来很简单。这是当时的Prompt IDE的最初版本:

想法其实也很简单:实现一个Side-by-side的提示词编辑功能,可以在界面上编辑运行,也可以直接改源码,这样就解决重口难调的问题,既方便又实用。

但是笔者当时犯了一个极大的错误,就是低估了这件事的难度。后来发现,要做到”可用“的程度,其实是非常困难的。

因此,接下来的时间断断续续在思考这件事,最终决定先放弃其他”Native",先把编辑器整清楚,从而产生了这个Prompt IDE。

Prompt IDE是如何运行的

从提示词存储格式开始

在函数模板中,提示词跟其他代码是同等重要的位置,因为提示词的变化也就意味着功能的变化。 因此,提示词最终需要存储为一个文本文件,这样才能跟版本控制等其他工具有机结合起来,也便于分享和协作。 但是,提示词应该怎么表示呢?

一些工具将提示词简单表示为一个文本,其中可能包含动态的内容(变量):

请将下面这句话翻译为{{language}}:

{{text}}

这看起来没有什么问题,在我们确定只用GPT的情况下。但是,如果我们需要使用不同的模型和参数呢?同样的提示词,在不同的模型和参数下,其运行结果是可能会有差别的。一种做法是,把这些信息也添加进去,例如:

-- Model=gpt-3.5-turbo-instruct Temperature=0.1

请将下面这句话翻译为{{language}}:

{{text}}

这样看起来不错,但是只适用于比较简单的补全(Completion)场景。补全是最早期的大模型调用方式,现在几乎已经被经过指令微调的对话(Chat)模式所取代。对于对话而言,会包含问答、少样本示例、参数等,当然我们也可以在之前的基础上稍作修改进行表示:

-- Model=gpt-3.5-turbo Temperature=0.1

System: 你是一只会模仿鹦鹉,从现在开始,我说什么,你跟着说什么
User: 记住了么?
Assistant:记住了么?
User: 停止,你现在不是鹦鹉了
Assistant:停止,你现在不是鹦鹉了
User: 好了,现在开始

但这样问题也很明显:因为没有严格的格式约束,很容易出错。在调用GPT的API时,我们是需要将其转换为messages数组来表示对话内容的,那么,如果这里面的格式有些问题,比如中英文的冒号不对,可能就会导致最终的结果不对。

我们需要有一种方式,可以来直观表示提示词,并且不容易出错的。

这里总共考虑过三种不同的格式:

  • XML:标记语法,可以通过XML DTD来校验格式,且可以通过<![CDATA[...]]>的方式来包裹多行文本,对于多行的提示词十分友好
  • YAML:也可以支持多行文本,缺点是没有标记,完全依靠缩进,容易乱
  • JSON:可以通过JSON Schema进行校验,缺点是需要对多行、特殊字符进行转义

最终,经过一些尝试后,最终选择了使用JSON表示。主要原因是,JSON格式校验比较完善,且天然对前端友好,更适合在Vs Code这种基于Web的体系下开发。 在Vs Code中编辑JSON时,也可以设置Schema来实现编辑器自带的格式校验和自动补全。如下是通过JSON来表示一个提示词的例子:

{
    "$schema": "../schema/chat-schema.json",
    "version": "chat@0.2",
    "engine": "chat-bison",
    "messages": [
        {
            "role": "user",
            "content": "Write a hello world in js"
        }
    ],
    "parameters": [
        {
            "name": "temperature",
            "value": 0.1
        }
    ]
}

对不同的大模型接口进行抽象

仅仅使用JSON是不够的,还需要对不同的大模型接口进行一次抽象。 例如,GPT中的System参数,在接口中是以一个特别的message表示的:

"messages": [
    {
      "role": "system",
      "content": "Hello"
    },

但是其他大模型并没有这个设计。除此之外,一些细微的地方,大家实现起来也各不相同,比如,大模型的回复,GPT叫”assistant",Minimax叫“BOT”。 所以,必须要对这些参数进行一次抽象,用一个统一的方式来表示提示词。目前的实现,主要参考了GPT和谷歌的AI Studio的做法。例如,通过context定义背景(类似GPT的System、Minimax的人设),通过examples定义Few-shot示例。 经过抽象后,最终形成一个JSON Schema表示,例如对话模式可以表示如下(部分):

{
  "context": {
    "type": "string"
  },
  "examples": {
    "type": "array",
    "minItems": 1,
    "items": {
      "$ref": "#/definitions/message"
    }
  },
  "messages": {
    "type": "array",
    "minItems": 1,
    "items": {
      "$ref": "#/definitions/message"
    }
  }
}

选取开发框架

参照Vs Code的插件开发文档可以很容易创建一个插件。但是要做一个复杂的交互页面,还需要进一步对框架进行定制:

  • 我们的场景其实是一个提示词编辑器,非常类似于WebView文档中的自定义编辑器的例子。因此,最佳的做法就是通过WebView实现一个网页来嵌入到Vs Code中
  • 纯原生的WebView是手写HTML来操作的,这样生产力太低了。vscode-webview-ui-toolkit是一个Vs Code的控件集,并提供了集中前端框架的集成示例,例如Angular, React等
  • 使用Typescript而不是Javascript,这样可以更好地进行静态类型检测

笔者采取的是React+vite的方式集成vscode-webview-ui-toolkit。 最终项目的结构大致如下:

- prompt-ide
  - package.json
  \ src
    - extension.ts
  \ webview-ui 
    - package.json
    \ src
  \ prompt-schema
    - package.json
    \ src

其中,prompt-schema为提示词格式的JSON Schema定义,通过dependency引用。webview-ui为WebView页面的实现,编译后嵌入插件中(index.js, index.css等)。 这样,其实相当于通过React生成了一个静态的HTML、JS等,然后在Vs Code插件中读取这个HTML,渲染一个Web页面,代码看起来有点不那么优雅:

// 省略了很多细节
export class PromptEditor implements vscode.CustomTextEditorProvider {
  public async resolveCustomTextEditor(
    document: vscode.TextDocument,
    webviewPanel: vscode.WebviewPanel,
    _token: vscode.CancellationToken
  ): Promise<void> {
    webviewPanel.webview.html = this._getWebviewContent(webviewPanel.webview);
  }

  private _getWebviewContent(webview: Webview) {
    const getAsset = (name: string) =>
      getUri(webview, this.context.extensionUri, ["webview-ui","build","assets",name,]);
    const nonce = getNonce();

    return /*html*/ `
        <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <link rel="stylesheet" type="text/css" href="${getAsset("index.css")}">
          <title>Prompt Editor</title>
        </head>
        <body>
          <div id="root"></div>
          <script type="module" nonce="${nonce}" src="${getAsset("index.js")}"></script>
        </body>
      </html>
    `;
  }
}

值得一提的是,微软为了避免大家做的插件乱七八糟真是操碎了心,不仅提供了详细的 UX设计指导,每一个控件什么时候用,什么时候不该用也写的明明白白

实现双向编辑同步

Prompt IDE的一个核心功能是双向同步。也就是说,在UI界面上的修改会改变文件内容;同时,改变文件内容也会同步到UI。 如果单纯只是在React中做一个编辑器,其实是十分简单的,我们可以通过一个State来维护当前编辑的提示词:

// webview-ui/src/App.tsx
function App() {
    const [prompt, setPrompt] = useState<ChatPrompt | CompletionPrompt | null>(null);
}

加载文件内容

但是,在Vs Code中打开文件的时候,如何读取文档内容并通过setPrompt初始化呢?

在上一节的代码中可以看到,如果在插件代码中,我们可以直接通过vscode.TextDocument对象获取文本文件的内容:

// public async resolveCustomTextEditor(
//     document: vscode.TextDocument,    ← 看这里
//     webviewPanel: vscode.WebviewPanel,
//     _token: vscode.CancellationToken
// )
document.getText()

然而,在webview-ui中无法直接通过这种方式获取到文档内容,只能通过通信的方式来实现,其原理如下:

[2]

因此,我们需要通过两步来实现:

首先,在插件启动的时候通过消息发送文件的内容完成初始化:

// src/PromptEditor.ts
import * as vscode from "vscode";
export class PromptEditor implements vscode.CustomTextEditorProvider {
  public async resolveCustomTextEditor(
    document: vscode.TextDocument,
    webviewPanel: vscode.WebviewPanel,
    _token: vscode.CancellationToken
  ): Promise<void> {
    const updateWebview = () => {
      webviewPanel.webview.postMessage({
        type: "update",
        text: document.getText(),
      });
    };
    updateWebview();
    // ...
  }
}


然后,在WebView中监听消息,加载文件内容

// webview-ui/src/App.tsx
function App() {
  const messageListener = (event: MessageEvent<any>) => {
    const message = event.data;
    if (message.type == "update") {
      try {
        const prompt = loadPrompt(message.text);
        setPrompt(prompt);
      } catch (error) {
        // ...
      }
    }
  };
  useEffect(() => {
    window.addEventListener("message", messageListener);
    return () => {
      window.removeEventListener("message", messageListener);
    };
  }, []);
}

将改动同步到文件

在Web编辑器上对提示词的修改等同于修改prompt状态(setPrompt),这里便不展开。 现在的问题是,如何把UI上面的修改同步到文件中呢?

从前面的消息模型可以知道,要想把WebView的变更同步到文档,也只能通过发消息的模式。 首先,在文档变更的时候,发送一个消息:

// webview-ui/src/App.tsx
import { vscode } from "./vscode";
function App() {
  const [prompt, setPrompt] = useState<ChatPrompt | CompletionPrompt | null>(null);
  const syncPrompt = (newPrompt: ChatPrompt | CompletionPrompt) => {
    vscode.postMessage({
      type: "sync",
      text: JSON.stringify(newPrompt, null, 2),
    });
  };
  const onPromptChanged = (newPrompt: ChatPrompt | CompletionPrompt) => {
    syncPrompt(newPrompt);
    setPrompt(newPrompt);
  };
}

然后,在插件中监听消息,通过vscode的API完成文档更新:

// src/PromptEditor.ts
export class PromptEditor implements vscode.CustomTextEditorProvider {
  public async resolveCustomTextEditor(
    document: vscode.TextDocument,
    webviewPanel: vscode.WebviewPanel,
    _token: vscode.CancellationToken
  ): Promise<void> {
    webviewPanel.webview.onDidReceiveMessage((e) => {
      switch (e.type) {
        case "sync":
          this.updateTextDocument(document, e.text);
          break;
        default:
          break;
      }
    });
  }
  private updateTextDocument(document: vscode.TextDocument, text: string) {
    const edit = new vscode.WorkspaceEdit();

    edit.replace(
      document.uri,
      new vscode.Range(0, 0, document.lineCount, 0),  // 做全量更新,如果做的更好可以只更新文档的部分内容
      text
    );

    return vscode.workspace.applyEdit(edit);
  }
}

注意到,在App.tsx中我们同样引入了一个“vscode”(import { vscode } from "./vscode";,不是说不能直接用么?

其实,这只是个障眼法,通过一个包装类屏蔽了Vscode和浏览器运行的差异:

// webview-ui/src/utilities/vscode.ts
class VSCodeAPIWrapper {
    private readonly vsCodeApi: WebviewApi<unknown> | undefined;
  
    constructor() {
      if (typeof acquireVsCodeApi === "function") {
        this.vsCodeApi = acquireVsCodeApi(); // 只有在vscode中运行时可以获取到,在浏览器直接运行时无法获取
      }
    }

    public postMessage(message: unknown) {
      if (this.vsCodeApi) {
        this.vsCodeApi.postMessage(message);
      } else {
        console.log(message);
      }
    }
}

将改动同步到WebView

如果用户在Vs Code的文本编辑器中直接修改了文件,如何同步到WebView呢?很简单,在插件中添加一个监听就可以了:

// public async resolveCustomTextEditor(
const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => {
    if (e.document.uri.toString() === document.uri.toString()) {
        updateWebview();
    }
});
webviewPanel.onDidDispose(() => {
    changeDocumentSubscription.dispose();
});

现在,可以实现双向同步的效果:

后台隐藏时Web页面销毁的问题

经过以上处理后,大多数情况下,编辑器都能够正常工作。但是有一种情况例外:如果把WebView页面放到后台,这时候在文本编辑器中修改了内容,是无法同步过去的。 当WebView不可见时,Web页面的内容会被销毁,并且在WebView回到前台时重新加载[3]

因此,对于一个有状态的WebView,一旦隐藏到后台,由于页面销毁,消息通讯也就中断了。解决方案有两种:

  • 直接暴力使用retainContextWhenHidden选项阻止vscode销毁
  • 通过getStatesetState保存并重新加载状态,这是更推荐的做法

例如,采取简单暴力的做法,可以这样实现:

// src/PromptEditor.ts
const providerRegistration = vscode.window.registerCustomEditorProvider(
    PromptEditor.viewType,
    provider,
    {
        webviewOptions: {
            retainContextWhenHidden: true,
        },
    }
);

一些Vs Code插件开发的经验

如何快速调试WebView

由于引入了React作为UI引擎,正常的插件开发流程是这样:

  • 首先编译打包webview-ui:cd webview-ui && npm run build
  • 运行插件(F5)

这样其实挺慢。由于webview-ui本身是一个完整的React工程,可以直接在浏览器上运行,与开发正常的React项目没有太大差别:

cd webview-ui
npm run start

但是,如果要调试一些带有状态待页面,比如加载一个文档,那么就需要通过window.postMessage模拟vscode发送消息了:

CSP 与自定义资源加载

在WebView中并不是什么都能做,插件通过Content Security Policy (CSP)对css、js、字体、请求调用等做了限制。这相当于定义了一个白名单,指定那些资源可以在WebView中访问,当然这样做的目的主要是为了安全起见。

例如,微软提供的例子中是这样定义CSP的:

<!-- https://github.com/microsoft/vscode-extension-samples/blob/main/custom-editor-sample/src/catScratchEditor.ts -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">

如果我们要调用外部的API,那么也必须要添加到CSP中,例如:

connect-src https://api.openai.com https://api.minimax.chat;

在插件中使用本地字体图片

在Vs Code中如果WebView中使用了本地的图片,最终是通过一个https的链接访问的, 例如,index.css文件我们是通过getUri方法获取到一个URI,然后加载到WebView中:

// src/PromptEditor.ts
const getAsset = (name: string) => getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", name]);

<link rel="stylesheet" type="text/css" href="${getAsset("index.css")}">

这样本地的资源最终会转换为一个https的虚拟地址来访问:

https://file+.vscode-resource.vscode-cdn.net/Users/.../prompt-ide/webview-ui/build/assets/index.css

对于图片和字体,都是如此。例如,如果我们在css中使用了一个图片:

.user-avatar {
    background-image: url(./user.png);
}

这样这个图片如果单独在React项目中运行是没有问题的,在Vs Code中运行就会显示不出来。 其实原理很简单:我们必须通过getUri来获取一个虚拟的地址,但是这个方法只有在插件中可用,在webview-ui中无法直接使用。 笔者解决这个问题的方法是,在插件的HTML中覆盖样式:

<!-- src/PromptEditor.ts --> 
<style nonce="${nonce}">
.user-avatar {
    background-image: url("${getAsset("user.png")}");
}
</style>

总结

本文初略总结了一些Prompt IDE插件实现的原理和一些难点,虽然并没有十分复杂的技术点,但有一定的学习成本。 现在,Prompt IDE已经发布了pre-release供尝鲜,可以在Vs Code插件中搜索或者在Marketplace上下载。

受限于笔者的水平、经验和精力,错误和不足之处在所难免,欢迎大家交流和PR。