即时应用


“UX风险”
在过去一年半的时间里,我们开发了用户不喜欢的特性,发现了他们不喜欢的地方,然后又尝试改进,由此我们对哪些特性会成功、哪些不会成功有了更清晰的认识。其中一个最大的预测指标就是我所称的“UX风险”。[0]
在我们内部形成的UX术语中,这基本上意味着用户可能因LLM出错而损失的精力,可以近似表示为
\[ p\cdot (t + a) \]
其中 \(p\) 是 LLM失败的概率,\(t\) 是 从用户请求到响应的时间,\(a\) 是失败时造成的任何额外的 “烦恼”。[1]
考虑一些例子:自动补全是很好的功能,因为它能快速预测一小部分文本,并且在出错时不会造成干扰。这意味着 \(p\)、\(t\) 和 \(a\) 都很小。另一方面,OpenAI 的新模型 o1-preview 令人印象深刻,但提供的 UX 不佳,因为它需要一些时间且不支持流式传输(\(t\) 较大),并且目前很难猜测它特别适合解决哪些任务(\(p\) 较大)。
当用户得知某个功能存在风险(高延迟、成功率低)时,他们就会停止使用,不幸的是(但可以理解),重新赢得他们的信任非常困难。这并非一个全新的概念,但在以LLM为中心的产品中确实更为显著。
虽然我们并非总是通过封闭形式方程来优先安排工作,但我们最近开始意识到,Continue内部存在一个明显的机会,可以显著降低风险:一个更好的“应用”按钮。
为什么“应用”如此重要?
聊天侧边栏是一个风险非常低的功能。考虑到它支持流式传输,你可以立即开始阅读,因此 \(t\) 很小。而 \(p\) 通常相当高:当生成代码时,即使LLM不完美,你仍然获得了一段新的代码作为参考、在此基础上构建,甚至可以重新提示来改进。它甚至不触碰你的源代码,所以 \(a\) 非常小。
由于这些原因,用户倾向于使用聊天功能也就不足为奇了。但最终,他们需要实际编辑源代码。一个成功的功能是 “编辑” (cmd/ctrl+I),用户可以高亮代码,按下 cmd/ctrl+I,输入指令来转换代码,并将差异直接流式传输到编辑器中。虽然这对于快速编辑(当你确信LLM会成功时)非常出色,但失败的成本相当高:它会用不满意的代码弄乱你的编辑器。[2]
“应用”之所以重要,是因为它允许用户从安全的聊天环境开始,然后在准备好时将代码桥接到编辑器。到他们触碰源代码时,已经对更改的质量有了信心。
实现“应用”的方法
“应用”问题可以被表述为“给定一个已存在的文件和一个已编辑文件草稿,生成完整的已编辑文件”。有很多方法可以实现这一点。
完整重写
最基本的选项是让LLM重写整个文件。这很简单,也正是LLM训练的任务,然而当一个1000行文件只有1行发生变化时,这种方式比可能的方式慢得多,成本也高得多。
应用时延迟重写
“延迟”重写是我们对模型输出类似 // ... rest of code here ...
这样的注释的称呼。许多基础模型似乎已经被训练成以可预测的方式输出这些注释。
我们可以让语言模型只重写改变的部分,而不是重写整个文件,使用延迟注释来指示代码的其余部分应该放在哪里。当它进行流式传输时,我们可以从原始文件中找到对应的代码并替换掉延迟注释。
这是一个相当复杂的解决方案,并且引入了一定概率未能正确解析/替换延迟注释。而且它在应用时仍然需要一个语言模型。
推测
推测解码 是一种推理技巧,使用一个较小的模型来预测下一个标记(草稿标记),而一个较大的模型在其后检查其工作。这使得推理速度大大加快,且质量可证明是等效的。
如果你通过除了较小语言模型之外的某些方式生成草稿标记,推测也同样有效。在将代码应用于文件的情况下,大部分内容会保持不变,因此草稿标记实际上可以是原始文件的一部分。
尽管它仍然需要一个语言模型,但推测不会引入任何解析失败的可能性。更大的问题是许多LLM API本身不支持推测,因此自带API密钥的Continue用户将无法使用它。
微调的“应用”模型
“应用”是一个比编写初始代码简单得多的任务,这使得一个较小的语言模型也能完成相同的任务这一点相当明显。Continue已经提供了选择较小模型(如Claude Haiku)用于“应用”的选项,但专门针对此任务训练的模型可能会在规模与质量的权衡上表现得更好。尽管如此,这仍然需要一个LLM,并且对于本地优先的用户来说,它增加了额外的参数集需要加载到非常有限的内存中。
聊天时延迟编辑 + 即时应用
最终,我们选择了这条路线。虽然我将在下面解释其内部工作原理,但我们选择此路线的原因是:
- 它是即时的,无需LLM进行应用
- “延迟”注释在聊天中实际上是一个不错的UI,否则LLM会重复很多不必要的代码 [3]
- 它不依赖于模型或API提供商,因此最终将适用于所有Continue用户
即时应用
让我们看看它在此文章顶部的GIF示例中是如何工作的。这是原始文件:
import { CheckIcon } from "@heroicons/react/24/outline";
import styled from "styled-components";
import { defaultBorderRadius, vscBackground, vscForeground } from ".";
interface CheckDivProps {
title: string;
checked: boolean;
onClick: () => void;
}
const StyledDiv = styled.div<{ checked: boolean }>`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: ${defaultBorderRadius};
cursor: pointer;
border: 1px solid ${vscForeground};
color: ${vscForeground};
background-color: ${vscBackground};
&:hover {
background-color: ${vscForeground};
color: ${vscBackground};
}
width: fit-content;
margin: 0.5rem;
height: 1.4em;
overflow: hidden;
text-overflow: ellipsis;
`;
export default function CheckDiv(props: CheckDivProps) {
const { title, checked, onClick } = props;
return (
<StyledDiv onClick={onClick} checked={checked}>
{checked && <CheckIcon width="1.4em" height="1.4em" />}
{title}
</StyledDiv>
);
}
步骤 1) 确保聊天时使用“延迟”格式
首先,我们为所有聊天消息向LLM提供一个系统提示,以便它知道在编辑代码片段时输出“延迟”注释。这些注释必须遵循特定的结构,以确保没有代码遗漏。在这个例子中,LLM生成了以下内容,通过不重复 CheckDivProps
和 StyledDiv
成功节省了标记:
import { CheckIcon } from "@heroicons/react/24/outline";
import styled from "styled-components";
import { defaultBorderRadius, vscBackground, vscForeground } from ".";
// ... existing code ...
const StyledCheckIcon = styled(CheckIcon)`
color: green;
`;
export default function CheckDiv(props: CheckDivProps) {
const { title, checked, onClick } = props;
return (
<StyledDiv onClick={onClick} checked={checked}>
{checked && <StyledCheckIcon width="1.4em" height="1.4em" />}
{title}
</StyledDiv>
);
}
步骤 2) 查找所有“延迟”注释的替换内容
现在我们有了文件新版本的表示,我们只需要用原始文件中对应的部分填充“延迟”注释。为此,我们将旧文件和新文件转换为其抽象语法树(ASTs)并执行搜索。我将在此处逐步讲解我们示例的算法。
在左侧,我们有原始文件的顶级节点(A = imports, B = CheckDivProps
, C = StyledDiv
, D = CheckDiv
),在右侧,我们有新文件的顶级节点(E = StyledCheckIcon
),其中 ...
表示延迟注释
在每一步中,我们将尝试找到左侧栈顶部节点的第一个匹配项。起初,这是 A
,我们立即在新文件中找到了完美匹配。这意味着没有任何变化,我们将它们都从栈中弹出。
现在,我们查找 B
的匹配项。没有找到,但在右侧栈的顶部有这个“延迟”注释,这意味着我们将直接吸收它,假设它原本就打算保持不变。
接下来,我们对 C
执行同样的操作。
最后,对于 D
,我们与 D'
找到了匹配。如何确定匹配是一个更深层次的问题,但在本例中,很明显 export default function CheckDiv(props: CheckDivProps)
的两个实例引用的是同一事物。
既然我们找到了匹配项,我们已经移过了新文件中的“延迟”注释,并将其从右侧栈中弹出。
我们还注意到在右列匹配项之前有一个未说明的节点:这意味着它是新的!但由于它在新版本的文件中已经被考虑到,我们无需做任何额外的事情。
最后,我们只剩下原始函数及其新版本。如果在 D'
中有任何“延迟”注释,那么我们将递归处理,但在本例中,我们只需将它们都从栈中弹出并结束。
步骤 3) 生成与原始文件的差异
完成此过程后,我们将获得一个延迟块替换列表,可用于重建新文件的完整表示。这将与原始文件进行差异比较,并在编辑器中显示供用户审查。
附注
[0] 如果让我猜的话,产品设计领域对此应该有官方术语
[1] 对于像 o1 这样的模型,风险将是一个更困难的问题,因为它们响应时间是可变的
[2] 尽管提供重新提示的机会降低了这种风险,这是我们最近改进的地方
[3] 这通常非常不真实,但原因是当你不得不复制粘贴时,你确实需要重写整个文件。有了“应用”按钮,就不再需要这样了。更好地节省标记和阅读时间。