Prompt IDE
你是怎样调提示词的呢?这看起来是个再简单不过的问题,但对于开发者而言,一直缺乏一个趁手的工具。目前可以用的这些工具大致有以下的问题:
- 可以同大模型聊天,但是不具备调参能力,例如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: 通过原生的大模型工具链来辅助我们开发
大致的设计是这样:
- 通过一个Vs Code插件来开发提示词,提示词最终存储为一个文件,有这特定的格式。这就是Native Editor
- 在开发提示词的过程中,可以通过大模型给出提示词调优的建议,类似与Js Lint,我们可以做一个Prompt Lint,不过当时经过测试发现效果并不好;还有比如自动生成人设,例如chatgpt-prompt-generator-v12通过一个预训练的BART模型自动生成提示词,也可以集成到插件中,做到自动补全。这是Native Tooling
- 提供不同语言的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中无法直接通过这种方式获取到文档内容,只能通过通信的方式来实现,其原理如下:
因此,我们需要通过两步来实现:
首先,在插件启动的时候通过消息发送文件的内容完成初始化:
// 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);
}
}
}