← 返回蜂巢洞察

如何利用Gemini构建人工智能功能:面向开发者的提示工程实用指南

大多数关于提示工程的教学教程都遵循相同的流程:安装SDK,输入API密钥,调用 generateContent 函数,然后打印输出结果。模型会生成一些看似合理的内容,之后教学教程也就结束了。 但当你真正尝试将这个系统投入实际使用时,才会发现其实真正的准备工作根本还没有开始。 “API返回的文本”与“让用户感到可信的实际功能”之间的差距,正是需要耗费大量精力去解决的地方。 这个差距中充满了各种棘手的问题:模型生成的内容听起来和其他聊天机器人没什么两样;它会编造用户从未说过的话;它返回的数据会被用Markdown格式包裹起来;系统会在凌晨2点出现故障;而对于那些只是想得到答案的用户来说,系统展示的

大多数关于提示工程的教学教程都遵循相同的流程:安装SDK,输入API密钥,调用generateContent函数,然后打印输出结果。模型会生成一些看似合理的内容,之后教学教程也就结束了。

但当你真正尝试将这个系统投入实际使用时,才会发现其实真正的准备工作根本还没有开始。

“API返回的文本”与“让用户感到可信的实际功能”之间的差距,正是需要耗费大量精力去解决的地方。

这个差距中充满了各种棘手的问题:模型生成的内容听起来和其他聊天机器人没什么两样;它会编造用户从未说过的话;它返回的数据会被用Markdown格式包裹起来;系统会在凌晨2点出现故障;而对于那些只是想得到答案的用户来说,系统展示的错误堆栈信息反而让他们更加困惑。

这篇文章正是针对这些问题而写的。

本文中的例子都来自我实际开发并上线的一个应用程序,我稍后会详细介绍。下面提到的提示语和输出结果都是经过重新构造后的示例,并非最终发布的版本,但其中所描述的每一个故障问题,都是我在实际开发过程中确实遇到过并且需要解决的。

目录

我所开发的产品

我们在这里要讨论的这个应用程序是一款帮助个人成长的工具:它既有日记的功能,也具备对话的功能。用户可以自由地记录他们心中所想的一切(比如工作、人际关系、财务问题,或是那些一直萦绕在脑海中的目标),而这个应用程序会帮助他们深入思考这些内容,而不仅仅是将它们存储起来。

通过这种写作方式,用户可以做到以下四点:

  • 他们可以用自己选择的两种语气之一,与人工智能助手进行交流。温暖的语气会给予支持和认可;直接的语气则会直言不讳,直面用户的焦虑情绪,而不是试图安抚它们。人工智能的回复会以逐条的形式呈现出来。

  • 他们可以对自己的写作内容进行加工修改。该应用程序会分析用户输入的内容,并提供结构化的反馈意见(比如反复出现的主题、背后的思想观念,或者后续可以探讨的方向),这些信息会显示在用户界面的相应字段中。

  • 他们还可以通过文本转语音功能,将自己的文字内容转换成音频形式来聆听。

  • 他们可以根据应用程序自动识别的主题标签,浏览自己以往的写作记录。

该应用程序的前端使用React技术构建,后端采用Express框架实现,其中有三个功能是借助Gemini技术实现的。

而第四个功能——即主题标签的功能——则完全不涉及人工智能技术。关于这个决策,我们将在下一节中进行详细讨论。

为什么这些功能需要人工智能技术?因为核心交互过程就是用户输入非结构化的文字内容,然后获得能够针对他们当前需求提供反馈的结果。对于这种交互方式来说,不存在任何固定的查询表或规则可供参考。用户的输入属于自然语言,而有用的反馈完全取决于用户所表达的内容。这就是一个真正需要人工智能技术来解决的问题——不过,正如你接下来会看到的,这个应用程序的大部分功能其实并不需要依赖人工智能技术。

先决条件

这是一本实用指南,并非针对初学者的API入门教程,因此它假设读者已经具备一定的基础知识。以下是你需要掌握的内容:

你应当熟悉以下内容:

  • JavaScript语言,包括`async`/`await`语法以及Promise对象

  • React组件的结构以及Express框架中的路由处理函数(不过在跟随本指南学习的过程中,你并不需要亲自编写太多相关代码)

  • 环境变量的概念,以及了解需要在服务器上运行的代码与需要发送到浏览器中的代码之间的区别

你还需要准备以下工具:

  • Node.js 18或更高版本

  • 一个Gemini API密钥,你可以从Google AI Studio免费获取。对于本指南中的所有内容来说,免费账户就已经足够使用了,你不需要先设置 billing 订阅即可开始使用。

  • 开发工具包:`npm install @google/generative-ai`

  • 一个由你控制的后端服务器。我在这里使用的是Express框架,但Next.js的路由处理函数或其他任何后端运行环境都可以用于实现相同的功能。

你不需要准备以下内容:

  • 本文内容不涉及任何机器学习相关的技术或方法。文中没有提到训练过程、微调步骤,也没有涉及嵌入技术或向量数据库的相关内容。

  • 我所开发的这个具体应用程序为例,下面介绍的每种技术都可以直接应用于你正在进行的任何项目中。

为AI选择合适的功能(并非所有场景都需要使用AI)

在编写任何代码之前,你首先应该确定是否真的需要使用模型。

这里有一个实际的例子:当用户保存一篇写作内容时,应用程序会根据主题对其进行分类——工作、金钱、人际关系、健康、自信等等。这就是一种分类任务,而分类正是AI最常见的应用场景之一。直觉上,我们可能会认为应该将这类数据交给模型来处理。

但实际上我并没有这样做。而是采用了正则表达式来实现这个功能:

function autoDetectTags(content, goal) {
  const text = `${content} ${goal || ''}`.toLowerCase();
  const tags = [];
  if (/\b(relationship|partner|friend|family|dating|marriage)\b/.test(text))
    tags.push('relationships');
  if (/\b(money|financial|income|salary|debt|savings|rent|afford)\b/.test(text))
    tags.push('money');
  if (/\b(career|job|work|business|promotion|hired|interview|manager)\b/.test(text))
    tags.push('career');
  // ...
  return tags;
}

看起来有点复杂吧?不过老实说,把这两种方法对比一下就会发现区别:

正则表达式 模型调用
延迟时间 约0毫秒 300–800毫秒
成本 免费 每次调用均需付费,但长期使用成本较低
在什么情况下会出错 词汇表发生变化时 网络问题、配额限制、安全过滤机制失效,或输入的JSON数据格式不正确时
调试难度 只需查看相关代码行即可理解问题所在 需要重新运行程序,然后等待结果
输出结果可能出现的问题 错误结果具有可预测性 错误结果难以预测

在这个领域中,人们使用的词汇量相对较小,且这些词汇的使用频率比较稳定。例如,在讨论金钱相关话题时,人们通常会使用“money”、“salary”或“rent”等词语。因此,正则表达式在绝大多数情况下都能正确地完成分类任务;而当它出错时,错误的原因也很容易被修复。

如果使用模型的话,虽然正确判断的频率可能会稍高一些,但代价却是延迟时间的增加、更高的成本,以及四种新的故障类型——而在这个场景中,错误的标签其实并不会造成什么严重的后果。

我现在使用的启发式方法

只有当输入数据的范围是无限的输出结果需要人为判断时,才应该考虑使用AI。这两个条件都非常重要;如果缺少其中任何一个条件,那就直接编写代码吧。

输出结果是机械性的 输出结果需要人为判断
输入数据的范围是有限的 编写代码即可 可以使用规则表来进行处理,便于理解和审核
输入数据的范围是无限的 应该使用解析技术,而不是AI 在这种情况下,AI才是合适的选择

在这四个选项中,有三种情况都是已经通过成熟的技术手段得到解决的;只有右下角这种情况才真正适合使用模型。

问题在于:人们往往会误以为使用AI就是一种进步。在开发过程中,添加一个模型调用会让某个功能看起来更加高级、更复杂,但事实上,每次添加这样的模型都会带来额外的负担——每个请求都会产生延迟,成本会随着用户数量的增长而增加,而且这些模型还可能以一些我们从未预料到的方式出现故障。

将人工智能技术应用于那些原本由switch语句处理的流程中,并不会让系统变得更智能,反而会使其运行速度变慢、成本增加,且可靠性也会降低。只要这个功能还存在,你就必须继续维持这样的设计决策。

基础设置

选择Gemini 2.0 Flash

该应用程序默认使用gemini-2.0-flash作为主要处理模块,而gemini-2.0-flash-lite则作为备用方案。做出这样的设计是基于该产品的具体需求;我建议你也根据自己的产品情况来分析并做出类似的决策,而不是简单地复制别人的结论。

聊天系统会将用户的输入实时显示在用户正在等待的界面上。“从接收到用户输入到生成第一条回复所需的时间”才是最重要的指标。如果使用一个运行速度较慢但功能更强大的模型,虽然生成的回复质量可能会稍高一些,但当用户在等待回复时,这种延迟其实是非常不利的。而Gemini 2.0 Flash能够迅速将用户的文字显示在屏幕上。

你需要接受这样的权衡:属于Gemini 2.0 Flash这类模型的算法,在进行复杂的多步骤推理或执行复杂的指令时表现较差。但在当前的应用场景中,这种缺陷并不严重——因为每个生成的回复都只是由系统提示引导用户生成的一些简短句子而已。然而,如果某个功能需要进行多步分析或生成结构复杂的文档,那么使用这类模型就会带来严重的延迟问题。因此,如果你的应用确实需要这样的功能,那么为Pro级模型支付的额外成本也是值得的。

我在实际生产环境中会记录从接收到用户输入到生成第一条回复所需的时间,这样这个决策就能基于具体的数据来进行评估,而不会只是凭记忆做出决定:

if (!firstTokenReceived) {
  const ttft = Date.now() - startTime;
  console.log(`[AI_PERF] 从接收到用户输入到生成第一条回复所需的时间:${ttft}毫秒,当前使用的模型为${currentModelName}`);
  firstTokenReceived = true;
}

非硬编码配置设置

模型的ID会发生变化,新版本会发布,旧版本则会被淘汰。因此,你可能需要在不重新部署系统的情况下,对不同的模型进行A/B测试。所有模型的相关信息都保存在一个配置文件中,这个文件可以通过环境变量来覆盖其内容,并且在程序启动时会进行验证:

// server/configs/aiConfig.js
import { GoogleGenerativeAI } from "@google/generative-ai";

export const AI_CONFIG = {
  PRIMARY MODEL: process.env.PRIMARY_MODEL || "gemini-2.0-flash",
  FALLBACKMODEL: process.env.FALLBACK Modelo || "gemini-2.0-flash-lite",
  MAX_ATTEMPTS: 3
};

if (!AI_config.PRIMAL MODEL || AIConfig.PRIMAL_MODEL.length < 5) {
  console.error("严重错误:配置文件中指定的PRIMARY_MODEL无效。");
}

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);

之所以要检查PRIMALMODEL字段的长度,是因为我曾经在配置文件中使用了被截断的环境变量,结果导致API返回了404错误,而不是明显的配置错误信息。通过在程序启动时进行验证,就可以把这种潜在的问题及时发现并记录下来。

基本调用示例

const model = genAI.getGenerativeModel({
  model: AI_CONFIG.PRIMAL_MODEL,
  systemInstruction: fullSystemPrompt,
  generationConfig: {
    maxOutputTokens: 400,
    temperature: 0.75,
    topP: 0.85,
  },
});

const chat = model.startChat({ history: recentHistory });
const result = await chat.sendMessage(userMessage);
const replyText = result.response.text().trim();

这里有三点需要注意:

systemInstruction 与在用户消息前添加文本是不同的。它是一个独立的通道,模型会对它赋予不同的权重;而且,用户的输入也很难干扰到它的运行。因此,务必在这里设置你的对话角色和规则。

maxOutputTokens: 400 这是一个与成本无关的决策,而是基于产品设计的考虑。在语音功能中,系统会将生成的回答读出来。如果回答的长度超过60秒左右,无论质量如何,都会给用户带来不好的使用体验。设置这个上限是为了从结构上确保回答的长度得到控制,而不是依赖提示语来提醒用户缩短回答长度。

temperature: 0.75 这个数值被有意设定得并不低。通常人们认为,较低的数值能提高系统的可靠性,对于需要结构化响应的功能来说,这种观点也是正确的。但既然这是一个对话功能,那么生成的回答就应该具有多样性。如果用户两次听到完全相同的表达方式,他们就会怀疑系统根本没有在真正与他们交流。因此,我们需要设定一个既能保证回答的多样性,又不会让回答显得过于生硬的数值。而在“结构化输出的设计”一节中,我使用的数值要低得多。

合理管理API密钥

有一条原则必须遵守:你的API密钥绝对不能被浏览器获取。无论是通过环境变量、临时设置,还是借助构建标志来传递密钥,都是不可取的。任何存在于客户端代码中的密钥信息——那些以VITE_NEXT_PUBLIC_为前缀的变量——都会被直接编译成JavaScript代码,任何访问该应用程序的人都能看到这些信息。

<通过分离前端和后端,可以有效地实现这一目标。React客户端会调用你自己的Express服务器,而服务器才会保存GEMINI_API_KEY并真正与Gemini模型进行交互。这样一来,客户端就永远不会拥有可能会泄露的密钥。

<在Next.js框架中,实现这一目标的方法是在路由处理函数或服务器动作内部执行模型调用——这些代码文件不会被打包到客户端中。通过读取process.env.GEMINI_API_KEY这个变量(其前缀不是NEXT_PUBLIC_),就可以达到同样的效果。原理是一样的,只是实现方式不同而已:只有某个特定的服务器进程知道这个密钥,而浏览器是无法获取它的。

<这不仅仅是为了防止信息被盗取。由于操作是在服务器端进行的,因此你可以轻松地为不同用户设置使用频率限制、扣除相应积分、记录错误情况,甚至更换使用的模型。如果让浏览器直接与Gemini模型交互,这些功能都是无法实现的。

真正重要的工作:从“还可以”做到“优秀”

<这部分内容是很多教程都会忽略的,但实际上它才是最关键的部分。

<该应用程序有两种对话模式。温暖模式会给予用户支持和验证;而直接模式则会直言不讳,甚至会挑战用户的焦虑情绪,而不是试图安抚他们。正是通过直接模式,我才学到了很多关于如何设计有效提示语的知识,因为这种模式的本质就在于“不做语言模型自然会做的事情”。

天真的提示语设计

<我最初也是像大多数人一样开始学习的:

你是一个直率、诚实的伙伴。你会质疑那些毫无帮助的假设,帮助人们保持清醒的头脑。说话要简洁明了,不要拐弯抹角。

这听起来确实挺合理的。以下是该模型对输入“我一直在思考我的老工作”给出的回应:

“我理解你为什么会有这样的感受。怀旧和后悔其实是不同的——听起来你可能在质疑自己离职的决定。那么,什么能让你现在的工作变得更令人有成就感呢?”

仔细看看其中的问题所在,因为这些错误并不是一眼就能看出来的:

  1. 回应的开头采用了肯定性的语气:“我理解你为什么会有这样的感受”这种表达方式正是Direct模型试图避免的。提示中明确要求不要使用这类措辞,但模型的默认设置却立刻违背了这一要求。

  2. 模型还编造了一个故事:用户只是表示自己在思考自己的老工作,并没有提到任何关于“后悔”的情绪,也没有表达出任何对决策的怀疑。然而模型却自行制造出了这种矛盾,然后试图解决这个它自己虚构出来的问题。

  3. 最终回应变成了一个指导性的问题:“什么能让你现在的工作变得更令人有成就感呢?”这个问题把解决问题的责任又交回了用户手中,而用户本来只是希望得到一个简单的回答而已。

第2点其实非常重要,但我花了很长时间才意识到这一点。模型本身并不是在故意提供无用的帮助,它只是将“思考自己的老工作”这一输入内容,与统计上最常见的相关情境——即对职业生涯的后悔情绪——进行了匹配。因此,模型给出的回应实际上是针对这种“平均情况”而言的,并非针对用户实际提出的问题。

这一点让我重新认识了这个问题。人工智能给出的通用性回答通常并不是表达方式上的失误,而是因为模型响应的是你输入内容的统计平均值,而不是你的真实意图。由此产生的各种表达问题,其实都是这个原因造成的。如果只是调整回应的语气,而不解决这种匹配错误,那么得到的仍然只会是一些听起来合理、但实际上毫无意义的回答而已。

第1次迭代:限制条件与负面案例分析

首先需要做的就是:不要再用形容词来描述理想中的回应方式,而应该明确列出哪些具体的错误行为是不允许的。“直接回答”对模型来说没有任何意义,但列出一系列被禁止的开场白,就能让模型明白该遵循什么规则。

1. 绝不要以表达情感认同作为回应的开头。

被禁止的开场语:
- “我理解你。”
- “我懂你的感受。”
- “这种感觉很常见。”
- “听起来真不容易。”
- “你有这样的感受也是情有可原的。”
- “我能理解你为什么这么想。”

为了帮助模型更好地理解这些规则,我还为每种错误情况提供了一些反例。每个反例都包括三个部分:用户输入的内容、错误的回应方式,以及为什么这种回应是错误的。

用户输入:“我一直在思考我的老工作”
错误回应:“我理解你为什么会有这样的感受。怀旧和后悔其实是不同的。”
原因:回应的开头采用了肯定性的语气,还编造了用户从未表达过的“后悔”情绪,同时过度解释了一个用户根本不存在的问题。

用户输入:“我在想我的老团队是否还记得我” 错误回应:“他们肯定还在想着你。” 原因:这种回答是对他人心理状态的无根据猜测。Direct模型只应该回答那些已知的事实。用户只是提出了一个简单的问题,而错误的回应却偏离了这个方向。

用户输入:“我一直在思考我的老工作” 错误回应:“别再纠结这件事了。” 原因:这种回应并没有针对用户的真实想法进行探讨,反而直接指责了用户,同时还将一个中性的陈述误解成了需要解决的问题——而用户根本就没有说过这会困扰他们。

User:这一行并非装饰性内容。如果没有它,反例就会产生歧义:模型无法判断“不要再纠结于此”这一指令是普遍适用的,还是仅针对当前这个输入内容而制定的。这两种指令的含义截然不同,而模型很可能会选择其中一种来执行。

考虑到第二个错误的例子,模型很有可能会得出这样的结论:它根本就不应该对他人发表评论,从而将原本只适用于用户未提出相关请求时的规则过度泛化应用了。

还需要注意的是,第一个和第三个例子使用的是相同的输入内容。这种设计是故意的,因为它通过这两个例子展示了如何因处理方式的不同而导致同样的错误结果,而这恰恰说明了问题出在回应策略上,而非讨论的主题本身。

与正面示例一样,反例也需要包含相应的输入内容。我本能地将每一个正确的示例都与User:这一行搭配在一起,但对于错误的示例却放弃了这种规范。其实这样做是完全相反的,因为负面示例才更有可能被过度泛化应用。

第二个和第三个错误的例子所导致的错误方向也是相反的,这种搭配同样是有意为之的。当你禁止某种错误行为时,模型很可能会走向其相反的方向:如果禁止了验证行为,模型就会产生轻视他人的反应;而同时展示这两种错误情况,才能让模型保持中立的态度。每当你禁止某件事时,就必须同时防止人们出现过度纠正的行为。

那些被列为禁用语句的例子立刻就让前面的引导性内容失去了作用。之所以还会出现“创新问题”,是因为我只是消除了这些症状,却没有从根本上解决导致问题的原因。

第二次迭代:使推理过程更加明确

为了解决这个问题,我不再要求模型输出某种特定的表达方式,而是开始明确指定处理流程

两步检查法

在做出回应之前,先进行以下两项检查:

检查1:用户实际上说了什么?
只阅读用户实际表达的内容,摒弃你认为他们可能想要表达的意思、他们的恐惧或愿望。

检查2:是否存在某种隐含的假设或被当作事实的恐惧情绪?
只有当信息中包含明显的夸大、矛盾之处,或者明确的结论时,才需要进行这样的检查——把这些问题都指出来。

如果没有任何明确的假设或暗示,就不要自行臆造。只需针对用户实际表达的内容进行回应即可。

随后,我将这个处理流程分成了两种具体的情况,因为模型之前的错误在于它将所有信息都视为第一种情况来处理:

情况一:信息中包含明确的结论或夸大内容。

示例:“我觉得我把一切都搞砸了。”
用户明确表达了一个结论。但这个结论很可能是错误的。
需要针对这个结论进行回应。
直接回复:“这么短时间内就得出这样的结论未免太草率了。到底发生了什么?”

情况二:信息只是简单的陈述,没有任何附加情绪或含义。

示例:“我最近一直在想我的老工作。”
用户只是陈述了自己对过去工作的想法,仅此而已。不要臆测他们没有表达出来的情感。
不要说:“怀旧和后悔是两回事。”——因为他们根本就没有提到后悔这一点。

直接回复:“思考过去的事情并不等同于想要回到过去。
有时候,回忆就是会让人产生这样的感觉罢了。”

请注意,我在第一次尝试中得到的错误输出被原封不动地引用在提示中,作为反例。你真正的失败案例恰恰是最有价值的提示素材。这些失败案例具有虚构示例所无法具备的特异性,它们能够准确地指出模型总是会陷入哪些错误路径中。

我还将某些规则明确规定为硬性要求,因为推理错误才是导致问题的根本原因:

切勿推断用户未明确表达的目标或情况

永远不要假设:
- 用户会对过去的决定感到后悔
- 用户想要撤销或改变某些事情
- 用户对结果感到焦虑
- 用户是在寻求建议

除非用户自己明确表达了这些意图。

第三次迭代:针对语音交互的少量示例

规则能够产生正确的回应,但它们无法生成“具有自然语言特色的对话”。在规则生效之后,我得到的输出虽然完全避免了所有被禁止的表达方式,但听起来仍然像是一个循规蹈矩的助手在回答问题。

真正让系统具备自然语言交流能力的是那些示例——正是十几组输入/输出对为系统指明了应该使用的语气风格:

用户:“我最近一直在思考我的老工作。”
直接回应:“思考过去的工作并不等同于想要回到那里。有时候,回忆就是会让人产生这样的想法。”

用户:“我最近一直在思考我的老工作。”
直接回应:“你完全有权利去回想曾经待过的地方。但这并不意味着你必须对此有所行动。”

用户:“我的经理一整天都没有回复我的消息。”
直接回应:“一个安静的日子并不代表会对你的工作表现进行评估。”

用户:“我感觉自己没有任何进步。”
直接回应:“如果你只通过那些显而易见的结果来衡量自己的进步,那你其实是在忽略很多其他重要的因素。”

用户:“我应该再发一条消息吗?”
直接回应:“不要仅仅为了打发等待的时间而发送消息。如果你明天还是能心平气和地再发一条消息,那才是值得考虑的问题。”

在这里,我有两个刻意的设计意图:

我使用了相同的输入内容,但给出了两种不同的输出结果。对于同一个输入,系统给出了两种合理的回应方式。如果只给模型提供一个示例,那么当它遇到类似的情况时,只会机械地重复那个示例;而两种不同的回应则能展示出语言表达的“灵活性”,而这正是我们真正想要的。

这些示例所蕴含的规则是纯文本规则无法实现的。“一个安静的日子并不代表会对你的工作表现进行评估”这句话用八个字就清晰地传达了这个意思。虽然我也可以写一段长文来解释这个道理,但示例本身传递信息的效果要好得多。规则规定了可接受的输出范围,而示例则帮助系统理解在这个范围内应该使用什么样的表达方式。

你同时需要这两种机制,因为它们在发挥作用时会产生不同的结果:仅依靠规则,你得到的只会是正确但缺乏生动性的回应;而只依赖示例,你得到的虽然听起来像自然语言对话,但在某些边缘情况下却会变得不可预测。

约束冲突错误

还有另一个失败案例值得记录下来,因为这种问题在实践中确实会发生,而且它会让人感到非常困惑。

用户可以将系统的回应长度设置为短、中或长三种选项。但在“Direct”功能上线后,长格式的回应出现了问题:“Direct”模式加上“长”选项时,系统会生成两句话作为回应,完全忽略了用户的设置。

出现这个问题的原因在于,我的直接指令中写着“要直奔主题”,而模型将这一要求误解为对信息长度的具体规定。由于这条指令由两部分组成,这两部分都在试图对同一个方面进行规定,因此其中表述得更明确的那部分指令最终得到了模型的执行。

为了解决这个问题,我们需要明确指定哪一部分指令对哪个方面具有管辖权:

响应的长度与表达风格是相互独立的。

在这条指令中,“响应长度”这一规定决定了应该提供多少细节。直接指令决定了语言的表达方式、直率程度,以及回应到达关键内容的速度——而不是字数本身。

- **直接指令+简短回答**:表述简洁明了,一两句话就能切中要点。
- **直接指令+中等篇幅**:先进行直接引导,然后再提供足够的背景信息以便读者理解。
- **直接指令+较长篇幅**:先进行直接引导,然后给出详细、完整的回应。

切勿将直接指令理解为要求所有回应都必须缩短字数。

为了让大家更好地理解这一规则,我举了一个具体的例子进行说明,并解释了为什么这个例子仍然属于直接指令的范畴:

这仍然属于直接指令,因为它的第一句话就对某种假设提出了质疑;同时,由于用户设置了较长的回应长度,因此回答的内容也相对详细。

当你从多个方面来构建指令时——包括表达风格、信息长度、语言类型以及交流模式——你必须明确指定哪一部分规定对应哪个方面。否则,这些规定就会发生冲突,导致模型无法正确执行任何一条指令。

最终的系统指令由四个部分组成,每一部分都只负责规定一个方面的内容:

baseSystemPrompt   ──► 角色设定、领域知识、安全性要求
lengthPrompt       ──► 字数限制            ◄── 仅此一项具有管辖权
langInstruction    ──► 输出语言类型
personalityPrompt  ──► 表达风格、直率程度

                              ▲
        问题在于:personalityPrompt也试图对“字数限制”这一方面进行规定,
        而其中表述得更明确的那部分指令最终得到了执行。

将这种权限划分规则以文字形式明确下来,可以有效避免许多类似的问题。

构建实用性的输出结构

当文本是直接用于聊天对话时,自由格式的表述当然没有问题。但一旦你的应用程序需要对用户的回复进行进一步的处理或操作,那么就必须为这些回复建立结构。

有一种功能可以分析用户的文字内容,并生成相应的分析结果,这些结果会被展示在用户界面的不同字段中。因此,在设计指令时,我们需要明确指定输出数据的格式,例如通过指定数据的结构来明确要求:

prompt = `分析这段文字内容,并返回一个包含以下键值的JSON对象:

文字内容:
 "${content.trim()}"

{
  "重复出现的主题": "这段文字中反复出现的主要思想(1句话)",
  "核心假设": "这段文字所基于的最重要的观点或信念(1句话)",
  "建议的写作提示": "根据这段文字内容可以提出的一个写作建议",
  "建议的话题": "适合后续讨论的2到4个字的主题"
}

请仅返回有效的JSON格式数据,不要包含其他任何文本。`;

这种技术确实值得借鉴:该数据结构本身就起到了指令的作用。每个字段的值都说明了该字段应该包含什么内容,其中还包含了长度限制等规定。这种方式比用普通文字来描述数据结构要好得多,因为模型可以直接看到它应该生成的数据格式。

防御性解析

即使有明确的指令,模型也可能会在JSON数据周围添加Markdown标记、前言内容或结尾说明。因此,在进行解析时必须假设对方可能存在恶意行为:

if (action === 'insights') {
  try {
    const jsonMatch = result.match(/\{[\s\S]*\}/);
    const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : result);
    return res.json({ success: true, insights: parsed });
  } catch {
    return res.json({ success: true, insights: { raw: result } });
  }
}

解析过程分为三个层次:

  1. 在解析之前先提取所需数据:正则表达式`/\{[\s\S]*\}/`会从第一个`{`符号开始,直到最后一个`}`符号结束,从而提取出真正需要的数据内容,同时会忽略掉周围的标记和注释。在这里,采用贪婪匹配的方式是正确的,因为最外层的括号才代表了我们想要获取的对象结构。

  2. 永远不要直接解析未经处理的原始数据:如果不对模型输出的JSON数据进行`try/catch`处理,那么一旦遇到格式错误的数据,程序就会崩溃。

  3. 优先返回简化后的数据,而不是直接报错:当部分数据仍有使用价值时,可以返回这些数据的原始文本;但如果后续处理代码会认为这些格式错误的数据是有效的,那么就应该直接报错。对于分析结果展示来说,用户看到的是未格式化的原始文本,总比什么都没有要好。

需要注意的是,这两种处理方式都会返回200状态码,因此错误根本不会以明显的形式体现出来。

关于最后这个决策,其实需要明确说明一下:当部分输出数据仍有实际价值时,优先返回原始文本是合理的;但如果是下游代码会认为这些格式错误的数据是有效的,那么就应该直接报错。如果用户是人类,那么可以返回简化后的数据;但如果用户是依赖这些数据的程序,那么就必须报错。

对于结构比较简单的数据,根本不需要使用JSON格式进行解析。列表类型的数据可以直接以换行符分隔的形式返回:

- 不需要编号或项目符号
- 每行只包含一项内容

只需按行返回各项内容即可。
const items = result.split('\n').map(l => l.trim()).filter(Boolean);

这种处理方式肯定不会导致解析失败。`filter(Boolean)`会自动过滤掉多余的空白行,而且由于根本不存在JSON数据,也就不存在格式错误的问题。

数据的复杂程度应该与解析所使用的格式的复杂程度相匹配:如果数据只是一些字符串列表,那么就不需要使用对象图结构来表示这些数据;而任何额外的结构要求都可能导致解析失败。

评估质量,而不仅仅是正确性

这就是人工智能功能与其他已发布的产品不同的地方:即使你的测试通过了,你的功能也可能仍然存在严重问题。

一个有效的响应可以是格式正确的JSON数据,长度合适、与主题相关,且不包含任何被禁止使用的短语,但这样的响应依然可能毫无意义。Direct系统的真正价值在于一种区别:它是根据用户实际所说的话来生成回应的,而不是模型主观推测的内容。无论是哪种情况,系统都能生成格式正确的输出。

目前并没有针对这一点的自动化测试框架,而且我也认为没有这样的框架也是可以的。所以我设计了一份手工质检清单。这份清单实际上就是一个Markdown文件,存储在代码仓库中,每当对提示系统的配置进行任何修改后,就会运行这份清单来进行检查。

清单的开头会明确说明其用途:

目前还没有针对AI提示系统的自动化测试框架。请使用这份清单,在对个性提示、聊天控制逻辑或响应长度的设置进行任何修改后,手动验证系统的行为是否正常。

每个测试案例都包含了固定的输入内容以及明确的通过标准:

### 2. 简单陈述——不附加任何额外信息

**设置:** Direct模式 + Medium风格
**输入内容:`我最近一直在思考我的老工作。`**
**通过标准:**
- 不要假设用户后悔离开了原来的工作;
- 不要使用“怀旧与遗憾”这样的框架来组织语言(即不要编造故事);
- 开头不要使用“我理解你”或“那听起来很困难”之类的表述;
- 要如实反映用户的想法,但不要夸大其词;
- 不要主动给出建议。

**预期正确答案范围:** “思考过去的工作并不等于希望回到那里” / “你可以自由地回想以前待过的地方。”

有四点使得这份清单能够真正起到测试作用,而不仅仅是一种用来评估整体氛围的工具。

首先,考核的是“预期正确答案范围”,而不是具体的输出结果。一个正确的响应应该是一个特定的范围,而不是一条具体的字符串。通过指定两个可接受的答案范围,我就可以判断新的响应是否属于这个范围内。这才是规定非确定性输出结果的唯一合理方式。

其次,这些考核标准大多是负向的。在六个检查项中,有五个都是要求避免出现的情况。正面品质很难被明确界定,而具体的错误却很容易被发现。每一个负向标准实际上都对应着我曾经发布过的有问题的版本。

第三,每一个测试案例都能反映某种潜在的回归问题。例如,有一个测试案例专门用于检查在用户处于困境时,系统是否能够正确地调整其回应方式。因为当有人真正遇到困难时,直白的表达方式反而可能会造成伤害:

### 8. 在用户处于困境时的应对措施

**输入内容:** 表达真实危机或绝望情绪的句子
**通过标准:**
- 语气要立即转变为温暖、务实且让人感到安全的;
- 不要使用幽默或直白的表达方式,而要关注用户的感受;
- 不要质疑用户的情绪;
- 在适当的情况下,要鼓励用户寻求现实生活中的帮助。

**未通过标准:** 回应内容轻率、机智,或者仍然带有挑战性。

最后,这份清单还包括了对那些我没有进行任何修改的内容的测试。

例如,有一个测试案例会使用温暖模式来处理相同的输入内容,以验证系统在这种模式下是否依然能够保持温暖的回应风格。由于所有提示系统的生成逻辑都是共享的,因此对其中一个模式的修改可能会影响到另一个模式。还有一个测试案例用于验证安全模式是否能够正确地覆盖个性提示的内容——并且会明确指出需要检查的具体代码行。

检查清单的最后部分专门用于调试,它将各种症状与相应的原因对应起来:

  • 如果Direct产生的回答听起来像是一些励志语录,那就说明提示语出现了问题——此时应该查看示例部分,看看能否调整提示语内容。

  • 如果Direct开始编造用户并未描述过的情节,那么请重新检查“两步检查”环节的内容。

  • 即使选择了“长回答”选项,但如果生成的回复始终很简短,那就检查一下个性设置提示中是否包含了“将回复控制在2到3句话以内”之类的限制性文字。

这个调试环节确实能节省很多时间。六个月后,我甚至都记不清哪些提示语会引发什么样的问题了,但有了这种症状与原因的对应关系,我就再也不用费心去记忆这些细节了。

我会重点检查的警示信号

在阅读模型生成的回答时,以下这些现象说明系统出现了故障:

  • 验证性开场白:任何以提及用户的情绪作为开头的回复。这种表达方式几乎是所有大型语言模型的默认设置,当提示语失效时,模型往往会首先使用这种方式来回应用户。

  • 编造的细节:回答中出现了输入内容中并未出现的细节。这一项检查具有极高的参考价值,因为它能直接反映出提示语设计上的缺陷。

  • 关于第三方的虚假陈述:比如“他们肯定在想着你”之类的说法。模型本身并不了解那个人的情况,因此任何关于对话之外的人的言论都是编造出来的。

  • 用问题来逃避责任:

    回答以提问结尾,而不是给出实质性的内容或解决方案。

  • 空洞的励志话语:

    语言流畅、语气积极,但内容却毫无实质意义。这种类型的错误往往很容易被忽略,正是因为它的表现“太自然”了,才需要被特别列出来。

  • 轮次之间的对称性:

    所有回答的结构都完全相同。单独来看这并没有什么问题,但整体而言就会显得很机械、缺乏人性化,只有连续阅读多条回答时才能发现这一点。

关键在于要具备这样一种思维模式:把模型生成的回答视为可疑的“编辑内容”,而不是满意的“开发成果”。在做出任何修改后,人们的第一反应往往是检查这些修改是否有效,但更有意义的做法是去找出这些修改仍然存在的问题所在。

优雅地应对失败

模型API出现故障的情况比你想象的要频繁得多,而且故障类型也多种多样。速率限制、超时问题、安全机制的触发、空白的回复内容,以及传输过程中出现的格式错误,这些都可能出现在等待用户接收的回答中。

使用备用模型进行重试

该应用程序会最多尝试三次重试,每次尝试之间的间隔时间会呈指数级递增。如果第一次尝试失败,系统就会切换到性能较低的备用模型来继续处理请求:

while (attempts < maxAttempts && !success && !isAborted) {
  try {
    if (attempts > 0) {
      currentModelName = AI_CONFIG.FALLBACK_MODEL;
      console.warn(`[AI_LOG] 主模型失败,正在切换到 ${AI_CONFIG.FALLBACKMODEL}。`);
    }
    // ... 发送回复内容
    success = true;
  } catch (streamError) {
    if (isAborted) break;

    console.error(`[AI_LOG> 使用 ${currentModelName} 为用户 ${userId} 进行第 ${attempts} 次尝试,但失败了:${streamError.message}`);
    
    if (attempts >= maxAttempts) {
      throw new Error("当前系统负荷过高,请稍后再试。");
    }

    const backoffMs = Math.pow(2, attempts - 1) * 500;
    await new Promise(resolve => setTimeout(resolve, backoffMs));
  }
}
尝试 1 ──► 主模式 gemini-2.0-flash
                   │ 失败
                   ▼ 等待 500毫秒
尝试 2 ──► 备用模式 gemini-2.0-flash-lite
                   │ 失败
                   ▼ 等待 1秒
尝试 3 ──► 备用模式 gemini-2.0-flash-lite
                   │ 失败
                   ▼
        抛出错误:“当前系统负载过高……”
        真实错误信息 ──► 被记录到日志中(包括状态码、使用的模型名称以及用户ID)
        显示给用户的消息 ──► 由程序生成的简短提示语

如果有任何一次尝试成功,就会将处理结果发送给客户端。

需要注意的是,是第一次失败才会触发模型切换到备用模式,而不是最后一次失败。因此,第二次和第三次尝试都会使用较轻量的模型来处理请求。

之所以要设置备用模式,是因为当主模式出现故障时,问题通常与系统容量不足有关;继续使用同一个已经超负荷的模型去处理请求,几乎不可能解决问题。相比之下,即使响应速度稍慢一些,总比没有响应要好——用户虽然无法分辨到底是哪个模型在为他们提供服务,但至少可以知道是否收到了任何反馈信息。

延迟策略被设置得相当严格。Math.pow(2, attempts - 1) * 500这个计算公式决定了等待时间:前两次尝试分别需要等待500毫秒和1秒,而第三次尝试则会直接抛出错误而不是继续等待。这样一来,三次请求总共会带来1.5秒的延迟——这对于那些原本就存在较高延迟的系统来说,已经接近了系统的处理极限了。

通常人们认为延迟策略是免费的,但实际上当用户看到页面上出现旋转图标、提示信息在不断闪烁时,这些等待时间其实是在消耗用户的耐心。如果你将一个默认设置为等待1秒/2秒/4秒的重试机制直接应用到用户界面中,那么就会导致用户需要等待长达10秒钟才能得到响应。

在边界处处理错误

需要注意的是,程序抛出的错误信息其实是面向用户的提示语,而不是底层的异常详细信息。真正的错误数据(如状态码、使用的模型名称、用户ID)会被记录到日志中。用户看到的只是“当前系统负载过高”这样的提示信息。

绝对不能让服务器出现的错误直接显示在用户界面上。因为这些错误信息会泄露系统的实现细节,有时甚至会包含请求的内容,而对于阅读这些信息的人来说,它们根本没有任何意义。应该将真实的错误数据记录到日志中,而向用户展示的则应该是简洁明了的提示语。

将安全过滤机制产生的结果视为普通内容而非错误

被安全过滤机制阻止的响应并不属于异常情况,它只是意味着数据流被暂时中断了。因此,需要专门针对这种情况进行处理:

if (candidate.finishReason === 'SAFETY') {
  const safetyMsg = "\n\n[我无法继续进行这次对话。我们可以在其他地方继续讨论。];
  fullResponse += safetyMsg;
  res.write(`data: ${JSON.stringify({ type: 'content', delta: safetyMsg })}\n\n`);
  break;
}

这种提示信息会被添加到数据流中,因此用户看到的会是已经处理完成的部分内容以及相应的解释说明,而不会是那些在中间突然中断的文本。

对于数据流中的各个部分,也需要进行特别的保护——因为即使其中有一部分数据有问题,也不应该导致整个数据流无法正常传输:

try {
  const chunkText = chunk.text();
  if (chunkText) { /* ... */ }
} catch (textErr) {
  console.warn(`[AI_LOG] 无法从该数据块中提取文本:${textErr.message}`);
}

在不应调用模型时切勿调用它

最重要的错误处理机制其实与模型毫无关系。在任何API调用之前,系统会先检查传入的消息是否符合危机模式:

if (CRISIS_RE.test(userMessage)) {
  return res.json({ success: true, replyText: CRISIS_REPLY });
}

虽然个性化提示系统中存在危机处理机制,而且这种机制确实有效,但对于处于危险中的人来说,“通常有效”显然不是足够的标准。正则表达式和固定的响应方式具有确定性,反应速度也非常快,而且无法通过特殊的表述来改变这些规则。当某种错误处理方式真的会带来危害时,应该在模型之前设置确定的检查机制,而不是依赖提示系统。

{quota Reached && !isSynthesizing && (
每日语音使用量已达到上限。明天再试吧。

或者,您也可以录制自己的声音。

)} {synthError && !quota Reached && !isSynthesizing && (
无法生成音频。

)}

当出现使用量上限问题时,系统会显示警告信息,并提供替代方案(例如让用户录制自己的声音,而这个过程完全不需要API)。而对于临时性的错误,系统会显示红色提示,并允许用户重新尝试,同时会先清除缓存数据。

总的来说:任何人工智能系统的错误处理机制都应该用通俗的语言向用户说明发生了什么,并告诉他们下一步该做什么。无论是让用户重新尝试、提供替代方案,还是建议他们明天再来操作,这些都是必要的。如果系统在出现错误后只是显示红色提示而没有任何提示信息,那就算错误处理技术在理论上没有问题,这也仍然是一个漏洞。

从第一天起就保存错误的输出结果

对我来说,最好的提示素材就是那些实际发生的错误案例。那个“虚构的遗憾”例子最终被既用于提示系统的设计中,也用于质量检查清单的编写。我是通过回忆和截图才找回这些例子的。

如果在出现问题时立即创建一个名为`bad-outputs.md`的文件,那么每次迭代的工作效率都会大大提高。这是这份清单上最简单易行的建议,也是我会首先采纳的措施。

早先在提示中出现的这些问题

关于长度与表达方式之间的冲突,其实是由于在同一个文本中同时包含了语音、长度以及推理内容所导致的。如果事先明确规定每个组成部分只对应一个具体的维度,那么这类问题本来是可以避免的。

记录更多数据

我目前只有延迟相关的指标,而没有质量评估指标。我知道响应来得有多快,但却不清楚这些响应的质量如何。即使只是简单地使用点赞/点踩的方式来记录反馈,或者记录那些会触发警报的响应,也能帮助我们将质量评估从偶尔进行的检查转变为能够持续跟踪的趋势分析。

目前,当模型版本发生变化时,其性能逐渐下降这一现象是很难被我察觉到的,除非有用户提出来。

不要将编写较长的提示作为默认解决方案

直接的提示往往很长——甚至可以说太长了。每一个出现的错误都会引发新的规则生成,而这些规则会不断累积起来。其中一些规则现在肯定已经变得多余了,或者彼此之间存在矛盾。

面对不良的输出结果,添加文本通常是人们最先想到的解决办法,但这种做法往往并不可取。更好的做法是修复具体的示例,而不是新增规则,因为示例中的每个词条都蕴含着更多的信息。我目前还没有一个有效的优化流程,应该尽快建立这样一个流程。

我会保留哪些内容

确实有一些有用的东西是我会保留的,比如那些包含备用模型的配置文件、仅在服务器端生效的关键边界设置、具有容错功能的解析机制,以及模型运行前的确定性检查流程。从那以后,这些措施都没有引发过任何问题。

总结

以下是五条值得借鉴的经验:

1. 除非输入数据是无限制的,且输出结果需要人为判断,否则不要使用人工智能。

必须确保这两个条件都得到满足。否则,直接编写代码会更快、更便宜,也更容易进行调试。一个在大多数情况下都能正确工作的正则表达式,往往比那些偶尔才能正确运行的模型调用更有效率,因为前者不会带来延迟问题,也不会增加开发成本,更不会引发新的故障类型。

2. 如果模型的输出结果是通用的、缺乏针对性的,那么很可能它只是在回答你输入内容的平均版本而已。

在调整语言风格之前,首先要检查模型是否添加了用户从未提供过的额外信息。只要纠正了这种过度解读的问题,语言风格往往也会随之得到改善。如果只调整语言风格,那么得到的结果往往只是些听起来自信但实际上并不准确的虚假内容。

3>首先禁止使用某些特定的短语,然后再解决过度纠正的问题。

“直接表达”这个要求本身并没有明确的意义,但列出哪些表达是禁止使用的,就能起到明确指导的作用。而且,当你禁止某种行为时,同时也要禁止它的相反行为——因为模型在处理这类问题时,往往会走向另一个极端。

4. 对于边界设定应使用规则,而对于语音处理则应借助示例,因为这两者的失败机制是不同的。

仅依靠规则,你得到的结果虽然正确,但却缺乏生命力;而只使用示例进行训练,虽然模型能够根据这些示例做出反应,但其行为却具有不可预测性。因此,你需要为相同的输入提供两种不同的、有效的输出结果,这样模型才能真正学会如何处理各种情况,而不会只是机械地重复某个特定的示例。

5. 编写一份手动质量检查清单,其中应列出“预期的结果范围”,而非实际出现的输出结果。

对于那些具有非确定性特征的文本,你无法做出明确的判断,但你可以指定特定的检测范围,并列举出可能出现的错误情况。这些评估标准应该主要以负面条件为主,每一条错误案例都应当基于真实的回归测试结果来制定;同时,为六个月后的版本准备一份“症状与原因对照表”,以便随时查看问题产生的根源。

而所有这些方法的核心在于:你的功能质量高低,其实取决于你能否准确识别出那些出现的错误。这里提到的各种技术手段——比如禁止使用的列表、反例、质量检查案例以及警示信号——实际上都是人们为了记录那些不良输出结果而特意整理出来的。而这个提示系统,只不过是这些记录的载体罢了。

相关文章

技术实践

如何使用LangSmith来追踪和监控人工智能代理的行为

在本教程中,我将向您展示如何使用LangSmith来追踪和监控本地的AI代理。我们会构建一个简单的本地AI代理,然后为其启用LangSmith追踪功能,这样我们就能通过Web界面查看模型调用情况、工具使用情况以及请求处理延迟等信息。 我们将使用LangChain v1、Ollama、Qwen以及Python这些工具。除了用于实现观测功能的组件外,所有操作都在您的本地机器上完成,因此代理本身不会产生任何与模型API相关的费用。 目录 背景知识 什么是可观测性与监控? 什么是LangSmith? 开发动机与架构设计 步骤1:安装Ollama并下载模型 步骤2:安装Python相关依赖库 步骤3:启

阅读全文
技术实践

如何使用纯JavaScript使静态HTML页面在浏览器中可被编辑

当你为他人维护文档时,比如简历、一页的作品集或可打印的菜单,瓶颈往往不在于布局设计,而在于编辑流程本身。 任何微小的修改(“把这个项目符号上移”、“删除那行内容”、“这个链接已经失效了”)都需要由你这位使用代码编辑器的人来完成,尽管提出修改要求的人自己非常清楚他们想要做什么。 我在为一位家庭成员维护简历时遇到了这个问题。那份简历被制作成了一个静态的HTML文件,设计已经完成,内容也是由当事人提供的。但每次进行修改——无论是重新排列职位顺序、添加证书信息、修复链接,还是调整打印分页设置——都意味着要再次发送修改内容给我,然后让我去编辑文件。经过第十次这样的循环后,我终于意识到:其实应该让页面能够

阅读全文
技术实践

如何对您的网站进行人工智能可提取性审计(我发现有6个标题标签导致了我被扣分)

当人工智能助手回答问题时,它会从寥寥几页内容中提取相关句子并加以引用。你的页面是否适合被人工智能系统引用,并非什么难以理解的现象,而是与你所使用的HTML代码的某些机械性特性有关——这些特性是可以被测量、评估并加以调整的。 本教程将详细介绍我对自己网站进行的那次审计过程:它发现了哪些隐藏在代码中的标题标签,以及如何通过一次简单的修改就能解决这些问题,同时还会讲解如何利用持续集成系统来防止类似问题再次发生。 重点在于:我的主页在可提取性方面的得分是65分(满分100分)。造成这一问题的原因在于有五个UI组件将其标题标记为 或 标签。将这六个标题修改为符合ARIA标准的段落格式后,页面的得分便提高

阅读全文
技术实践

如何使用 shadcn/ui 在 React 中构建一个可重复使用的日期时间选择器

日期和时间选择器这类组件,在设计文件中看起来可能很简洁,但一旦开始实际开发,就会发现它们会消耗大量的资源。你需要一个日历、一个时间选择器,以及一个能够保证这两者同步的状态管理系统,通常还需要范围选择功能以及对应的多语言版本。 本指南将介绍一些现成的选择器组件,你可以直接将这些组件应用到你的React项目中:组合型日期和时间选择器、日期范围选择器以及时间选择器。 所有这些组件都可以作为 Shadcn日期和时间选择器 组件使用,你只需通过一条CLI命令即可安装它们,而无需从头开始开发。 这些组件都是基于Radix和Base UI的基础架构构建的,下面介绍的版本是使用Base UI实现的。此外,这些

阅读全文