流式输出跑通本身不难:服务端把上游返回的片段一段段转发给前端,前端追加显示。正常情况下体验很好。四个问题都是我人为中断或断网之后才出现的。
一是中断之后已显示的内容全部消失。我的落库是在生成完成时做的。用户看着回答一点点出来、看到一半觉得够了点了中断——刷新页面之后这条回答完全不见了,因为它从来没有被存下来。网络断开的情况一样。用户的感受是「刚才那段挺有用的内容白看了」。
二是半截回答被当成正常历史发给了模型。我后来改成了边接收边落库,但没有标记它是不完整的。结果是下一轮对话时,我把这段半截内容当作助手的正常回答拼进了上下文——模型看到一段没说完的话,后续回答明显被带偏了,有时候会接着那半句继续说。这个问题很隐蔽,我一开始以为是模型不稳定。
三是长回答时前端明显卡顿。我在每收到一个片段就触发一次渲染。片段是很碎的,一个长回答会触发上千次更新——而每次更新都要重新解析整段内容的格式(加粗、列表、代码块),在长回答后半段已经很卡了。
四是生成过程中格式一直在跳变。代码块的起始标记先到、结束标记还没到,渲染器把后面所有内容都当成了代码;下一个片段到了之后又变回来。整段回答在生成过程中不停地在「代码块」和「普通文本」之间闪。
另外还有一个我差点没发现的问题:前端点中断只是停止了显示,而服务端到上游的调用还在继续——那部分内容我照样付费了。
所以四个改动:改为边接收边追加落库、中断与失败显式标记为不完整且不参与后续上下文、前端按帧节流渲染、对未闭合的代码块做临时补全,另外让中断真正终止上游调用。
这个模块最想说的一句话是:流式输出把「一次请求一次响应」变成了「一段持续的过程」,而过程是可以在任意一点断掉的——所以每一个中间状态都必须是可解释的。我最初的实现只考虑了「顺利跑完」这一种结局,所以中断之后留下的是一个既没存下来、又没有标记、还会污染后续对话的烂摊子。
边接收边落库,而不是完成后一次性落库。完成后落库的写入次数最少、实现也最简单。但它的前提是「一定能顺利完成」——而流式输出恰恰是可以在任意一点断掉的。代价是写入次数增加,所以我做了批量与节流(攒够一定量或一定时间才写一次)。判据是「中间断掉时用户已经看到的内容值不值得保留」:值得——用户看着内容一点点出来,它在他眼里已经是「已经得到的东西」了,凭空消失是最糟的体验。
不完整的回答默认不参与上下文,而不是照常拼进去。照常拼进去实现最简单(不用区分状态)。但半截内容会把模型带偏——它会试图接着那半句说下去,而用户完全不知道为什么回答变奇怪了。代价是要维护回答的完整性状态、拼接时要过滤。判据是「这段数据作为输入是否可信」:不可信的输入不该混进正常流程。而「默认不带、需要时显式带」这个模式让特殊需求(接着继续写)仍然可以满足。
中断要终止上游调用,而不是只停前端显示。只停前端实现最省事(一行代码)。但服务端到上游的调用还在跑,那部分内容我照样付费——而这是这个项目里唯一一个「不修就一直在漏钱」的问题。判据是「这个操作背后还有没有正在持续消耗的资源」:有,那就必须真正把它停掉。这一点我是在对账用量时才发现的,说明它很容易被忽略。
前端按帧节流而不是逐片段渲染。逐片段渲染的响应最即时。但片段的到达频率远高于屏幕刷新频率,多余的更新对用户完全不可见,却实实在在地消耗了性能——而每次更新都要重新解析整段格式,成本随回答变长而增加。判据是「更新频率有没有超过用户能感知的上限」:超过了,那多出来的部分就是纯浪费。
踩过的坑一:中断之后已显示的内容全部消失。因为落库在生成完成时才做。教训是:流式输出把「一次请求一次响应」变成了「一段持续的过程」,而过程可以在任意一点断掉——所以每一个中间状态都必须是可解释、可保存的。我最初的实现只考虑了「顺利跑完」这一种结局。
踩过的坑二:半截回答污染后续对话,而我以为是模型不稳定。现象是接下来几轮的回答变得很奇怪、有时候接着上一句没说完的话继续。我怀疑过模型、怀疑过参数,查了很久才想到去看自己拼的上下文长什么样——一眼就看到了那段半截的内容。教训是:模型表现异常时,先检查自己送进去的输入——很多时候不是模型的问题,是输入里有脏东西。这个排查顺序帮我省了很多时间。
踩过的坑三:前端点中断但费用还在产生。我是把接口用量和自己的账单对照时发现数字对不上的。教训是:任何「取消 / 停止」的操作都要确认它停掉的是整条链路,而不只是你眼前这一段——尤其当链路的另一端在花钱。
没做的部分:没做「断点续传式的继续生成」(从中断处让模型精确接着往下写)。它需要把已生成的部分作为提示的一部分回传,而模型并不保证从断点无缝衔接,拼出来的内容经常有重复或跳跃。我做的是「重新生成」并保留原记录——取舍依据是「无法保证质量的功能不如不做,但要说清它难在哪」。
中断保留内容是断言型用例,也是这个模块最核心的一条:在生成到一半时点中断,断言数据库里存有已生成的部分、状态为「已中断」、刷新页面后内容仍在;断网同理。
上游调用是否真的被终止,要用接口用量来验证:「在生成到一半时中断,对比中断前后接口报告的用量」。报法是「修复前中断后用量仍继续增长、修复后停止增长」——这个数字来自接口方的用量统计,是可核对的外部证据,比自己打日志更有说服力。
上下文污染要用可复现的方式验证:构造一条被中断的半截回答,然后继续提问,断言拼给模型的上下文里不包含这段内容(直接检查请求体,不要靠观察回答质量——那不可复现)。
渲染性能要报回答长度与更新次数:「一条约 N 字的回答,逐片段渲染触发约 M 次更新、按帧节流后降到 K 次」,并报后半段的渲染耗时对比。更新次数比「感觉流畅了」具体得多。
代码块闪烁:断言生成过程中未闭合的代码块被临时补全、整段内容不出现「代码块与普通文本反复切换」。这条可以录屏逐帧看,也可以断言渲染前的补全逻辑生效。
自动滚动:断言用户手动上滑后自动滚动停止、滑回底部后恢复。
并发提问:断言生成中无法再次提交,或提交后进入排队而不是并发调用。
不要报什么:不要报「回答质量提升」——那取决于模型,不是我的工作。该报的是「中断后已生成内容仍在库中且状态正确」「中断后接口用量停止增长」「上下文请求体中不含不完整回答」「某长度回答的渲染更新次数从 M 降到 K」这几件可核对的事。
多轮对话的实现看起来很自然:把之前的问答全部拼成一个列表,连同新问题一起发过去。短对话完全没问题,我用了一段时间之后才撞上四个问题。
一是聊到几十轮之后调用直接失败。报错说超过了上下文长度上限。我一开始完全没意识到有这个限制——而它的表现是「用得越久越容易坏」,越是重度使用的会话越先坏。
二是成本随对话轮次线性增长。我每一轮都把全部历史发过去。第一轮只发一个问题,第二十轮要发二十轮的全部内容——而输入是计费的。同样一个问题,在长会话里问和在新会话里问,费用差很多倍。我是对账用量时才发现自己在为大量重复的历史反复付费。
三是我按字数估算长度,估得不准。我最初用「字符数除以一个系数」来估算,然后按估算值裁剪。结果中英文混排和代码块的偏差非常大——有时候估少了、裁完还是超限;有时候估多了、明明还有空间却把有用的历史裁掉了。
四是只按输入算预算,回答长的时候还是超限。我把输入裁到刚好不超上限,但模型的输出也要占同一个窗口——回答一长就又超了,而这次失败发生在生成过程中,用户已经看到半截回答了。
所以四个改动:超限直接失败改为按预算主动裁剪、裁剪策略为保留首轮设定加最近若干轮、用与计费一致的方式计算真实长度而不是按字数估算、为模型输出预留固定空间,另外补了单轮超长输入的提前拦截。
这个模块最想说的一句话是:上下文窗口是一个必须主动管理的预算,而不是一个「到了再说」的限制。我原来的心态是「先全都发过去,超了再处理」,而超了的表现是调用直接失败——也就是说我把一个可以提前规避的问题留成了运行时错误。而且这个预算同时决定了成本:管好它既是为了不出错,也是为了少花钱。
主动按预算裁剪,而不是「先全发过去、超了再处理」。后者实现最简单(不用算长度、不用裁)。但它把一个可以提前规避的问题留成了运行时错误——而且是「用得越久越容易坏」,越是重度使用的会话越先坏。代价是要引入长度计算和裁剪逻辑。判据是「这个错误能不能在发出请求之前就避免」:能,那就不该留到运行时。而且裁剪顺带解决了成本问题,这是我一开始没意识到的额外收益。
用与计费一致的方式计算长度,而不是按字数估算。估算实现简单、也不需要额外依赖。但偏差在中英文混排和代码块上非常大——估少了裁完还超限(等于没裁),估多了把有用的历史白白裁掉。代价是要引入一个计数的实现、并且它要和计费口径对齐。判据是「估算的偏差会不会导致功能失效」:会(仍然超限),那就必须用真实计数。而且用真实计数之后,我才能准确算出每轮的成本——这两件事是同一个基础。
裁剪策略选「保留首轮设定 + 最近若干轮」,而不是更复杂的方案。更聪明的做法是把较早的对话摘要压缩成一段简短的描述再带上。但那意味着每次裁剪都要额外调用一次模型来做摘要——多一次调用就是多一份费用和延迟,而且摘要本身可能丢掉关键信息或引入错误。我选了简单策略,但把它的局限写清楚了:较早的具体细节确实会丢。取舍依据是「摘要的成本与不确定性,是否值得换回那部分记忆」——在我的使用场景下不值得,而且我能说清完整方案是什么。
被裁掉要告诉用户,而不是静默处理。静默处理界面更干净。但用户会发现模型「忘记」了前面说过的话,而他不知道原因,只会觉得产品不行。一行提示的成本几乎为零,却把一个「产品缺陷」变成了「可以理解的限制」。这一条我觉得是很划算的产品细节。
踩过的坑一:聊到几十轮后调用直接失败,我完全没意识到有窗口上限。而它的表现很特别:用得越久越容易坏,越是我自己重度使用的那个会话越先坏。教训是:调用第三方接口时,要先把它的限制(长度上限、频率限制、单次大小)读一遍并在代码里显式处理——而不是等它报错了再回头看文档。我现在接任何外部接口都会先列一遍它的约束。
踩过的坑二:按字数估算长度,裁完还是超限。我一开始觉得「差不多就行」。但代码块的偏差大到让估算完全失效。教训是:当一个估算值被用来做「是否超限」这种硬判断时,它的偏差就是功能的失效率——这种地方不能用估算。
踩过的坑三:只按输入算预算,输出超限导致回答被截断在半路。用户已经看到半截回答了,这比一开始就失败更糟。教训是:共享的资源要按「所有使用方」一起算预算,不能只算自己看得见的那一部分。
没做的部分:没做基于向量检索的长期记忆(把全部历史存起来、按相关性检索出最相关的几段带上)。它是解决「长对话记忆」的正规做法,但需要向量存储、嵌入调用(又是一份费用)和检索质量的调优。取舍依据是「它引入的组件和不确定性远超我这个项目的需要」——不过我能说清它的思路和要解决的问题,这比假装自己做过要好。
调用失败率是这个模块最直接的指标:造一个几十轮的长对话,断言改造前在第 N 轮左右开始持续调用失败、改造后可以继续对话。报法要说明「N 轮」是在什么样的对话内容下(每轮多长、有没有代码块),因为轮数上限完全取决于内容长度。
长度计算的准确性要和计费口径对照:「用真实计数方式算出的输入长度,与接口返回的用量报告对比,偏差在可接受范围内」;再报「按字数估算时的偏差有多大」作为对照——这个对照直接说明了为什么不能用估算。
成本随轮次的变化是这个模块最有说服力的数字:报「同一个问题在第 1 轮与第 20 轮提问时的输入长度与费用」,改造前随轮次线性增长、改造后稳定在预算上限附近。费用按接口的计费规则手算,并与账单核对——要说明是手算的还是账单上的,不要混为一谈。
输出预留的有效性:构造一个会产生很长回答的提问,断言不会在生成过程中因超限而中断。
裁剪策略的正确性用断言型用例:断言首轮设定始终被保留、最近若干轮被保留、被裁的是中间轮次、且问答成对被裁而不是只裁一半。
用户提示:断言发生裁剪时界面出现「较早的对话已省略」的标注。
超长输入拦截:粘贴一段超过输入额度的内容,断言在发出请求之前就被拦截并给出建议,不产生任何调用。
可观测数据:断言每轮都记录了输入长度、输出长度、是否裁剪、裁掉几轮。这些是后续调参的依据。
不要报什么:不要报「上下文利用率」这种自造指标——没有公认口径。该报的是「长对话从第 N 轮起失败改为可继续」「真实计数与用量报告的偏差、以及按字数估算的偏差对照」「同一问题在第 1 轮与第 20 轮的输入长度与费用对比」「超长输入在发出前被拦截」这几件可核对的事。
这个项目和我做过的其他东西有一个本质区别:每一次调用都在花我自己的钱。所以「成本」不是一个可以留到以后优化的事项,它从第一天就是约束。四个问题都是我看账单之后才处理的。
一是相同的问题反复调用、反复付费。我自己在调试的时候会把同一个问题问很多遍(改了提示词、改了参数、或者只是想再看一遍回答)。每一遍都是一次完整的调用和费用。而其中很大一部分的输入完全一样,回答也没有必要不同。
二是我不知道钱花在哪了。我一开始只有一个总账单,没有逐次记录用量。结果是月底看到一个数字,但完全无法定位是哪些调用贵、是输入贵还是输出贵、有没有异常的调用。更麻烦的是我发现账单比我预期高不少,却查不出原因。
三是失败之后无脑重试,把钱重复烧掉。我加了统一的重试(失败就重试几次)。但有些失败是确定性的——参数不对、内容被上游拒绝——这类失败重试一百次也是失败,而每次尝试仍然可能计费。我等于在为注定失败的请求反复付钱。
四是没有任何上限,出问题时会一直漏。我在调试一个循环的时候不小心让它连续发了很多次调用——而系统没有任何机制阻止它,直到我自己发现。如果我睡着了,损失会大得多。
所以四个改动:相同问题命中缓存直接返回、逐次记录用量并折算费用入库、重试区分可重试与确定性失败、加日预算上限与单用户频率限制,另外把失败做成明确的降级出口。
这个模块最想说的一句话是:当一个操作会产生真实费用时,「哪些请求根本不该发出去」这个问题的优先级高于「怎么让请求更快」。我原来做优化的思路都是「怎么让它更快、更稳」,而这个项目让我第一次认真想「怎么让它别发」——缓存、确定性失败不重试、预算上限、频率限制,四件事全都是在减少调用次数,而不是优化单次调用。
对相同问题做缓存,代价是「同一个问题拿到同一个回答」。不缓存的话每次都是新生成的回答(有时确实会更好)。但我自己在调试和使用时会把同一个问题问很多遍,每一遍都是一次完整的费用——而其中很大一部分输入完全一样、回答也没必要不同。我的处理是缓存有效期设得短,并且提供「重新生成」显式绕过——默认省钱、需要时可以要新的。判据是「这次调用的输入和上次完全一样吗」:一样,那默认复用是合理的。
重试区分失败类型,这是我认为最容易被忽略但最实在的一个改动。统一重试实现最简单(一个装饰器就搞定)。但确定性失败重试一百次也是失败,而每次尝试仍可能计费——我等于在为注定失败的请求反复付钱。代价是要按错误类型分类,而分类要读接口文档、还要处理文档没写清的情况。判据是「重试有没有可能成功」:没有可能,那重试就是纯损失。这一条在任何调用计费接口的场景里都成立,不只是大模型。
加日预算上限,即使它会导致「今天用不了」。不加上限的话功能永远可用。但我真的遇到过一次误操作导致连续大量调用,而系统完全没有阻止——如果我当时睡着了,损失会大得多。代价是极端情况下用户(也就是我)会被挡住。判据是「失控的上限在哪」:没有上限就意味着损失没有上限,而一个可以被自己解除的限制,比一个没有限制的账单安全得多。
逐次记账而不是只看总账单。只看总账单省掉了记录和折算的工作。但它让所有成本问题都不可定位——我发现账单比预期高不少,却完全查不出原因。逐次记账之后才发现问题主要出在长会话上(每轮携带大量历史),这直接推动了模块二的裁剪。判据是「出问题时你能不能定位」:不能,那就必须先把观测建起来。这个模块和模块二是这么连起来的:先能看见,才知道该优化什么。
踩过的坑一:账单比预期高,但查不出原因。因为我只有一个总数、没有逐次记录。教训是:涉及费用的调用必须逐次留痕(用量、折算费用、结果状态),并且按维度聚合——不然你只能知道「贵」,不知道「为什么贵」。而记账建好之后我很快定位到是长会话导致的输入膨胀。
踩过的坑二:统一重试把确定性失败也重试了。参数错误重试三次,三次都失败、三次都可能计费。教训是:重试的前提是「这次失败是偶然的」——如果失败是必然的,重试只是把一次损失变成三次。我现在写任何重试逻辑都会先列一遍「哪些错误重试有意义」。
踩过的坑三:误操作导致连续大量调用,而系统没有任何阻止。我是在调试一个循环时不小心触发的,自己发现之后才停下来。教训是:任何会产生真实成本的操作都要有一个「熔断」的上限,而且这个上限应该由系统强制,不能依赖操作者不出错——因为出错的往往就是操作者本人。
没做的部分:没做语义相似问题的缓存(问法不同但意思一样也能命中)。它需要嵌入向量和相似度阈值,而嵌入本身也是一次收费调用——为了省一次调用先花一次调用,收益要算清楚才划算;而且阈值定不准会返回不相关的旧回答,那比多花一次钱更糟。取舍依据是「引入的成本与不确定性可能超过节省」。
缓存命中率是这个模块最直接的收益指标:报「统计区间内的调用请求中命中缓存的比例」,并说明缓存键是「规范化问题指纹 + 会话设定」、有效期多长。要诚实说明我自己调试时的重复提问占了其中很大一部分——这个坦白反而让数字更可信。
费用对比要说清折算方式:「按接口计费规则手算的单次调用费用,与账单核对后偏差在可接受范围内」。然后报「相同问题重复提问 N 次的费用,改造前是 N 倍单次费用、改造后是 1 倍」——这个对比很直观也很容易核对。
确定性失败的浪费要用失败原因分布来说明:报「失败原因分布中确定性失败占比 X%,改造前这部分每次都会被重试 N 遍」。这个数字直接量化了「统一重试」浪费掉多少。
预算上限用断言型用例:把日预算设成一个很小的值,断言超出后调用被暂停、界面提示「今日额度已用完」而不是笼统报错、且历史记录仍可查。
频率限制:用脚本在短时间内连续请求,断言超出频率后被拒绝、且被拒绝的请求不产生上游调用(可以看用量记录确认)。
重试分类:分别构造超时与参数错误两种失败,断言前者被退避重试、后者不重试。后者是踩坑的直接回归。
降级:让上游持续失败,断言连续失败达到阈值后进入「暂不可用」提示而不是无限转圈;断言用户的问题被保留、不需要重新输入。
记账完整性:断言每次调用(含失败的)都产生了一条记录,包含用量、折算费用、结果状态。
不要报什么:不要报「成本降低 N%」而不说基线——重复提问的比例完全取决于使用方式。该报的是「缓存命中率及缓存键构成」「手算费用与账单核对的偏差」「相同问题重复 N 次的费用从 N 倍降到 1 倍」「确定性失败不再被重试」「超出预算时正确暂停并提示」这几件可核对的事。
提示词是这个项目里我改动最频繁的东西——几乎每天都在调。而我最初把它当成普通的代码字符串写死在类里,四个问题很快就来了。
一是改坏了回不去。我调提示词的方式是直接改那段字符串。有一次我为了让回答更简洁改了一版,用了两天发现它在处理代码问题时变差了——但我已经记不清原来那段是怎么写的了。提示词不像代码逻辑,它是一整段自然语言,改动之后原文就真的没了。
二是说不清一次改动是变好还是变坏。我每次改完就随便问几个问题,觉得「好像好一点」就留下。但我问的问题每次都不一样、而且往往是我刚好在想的那个问题——这等于没有对比。更糟的是我会不自觉地挑那些能证明「改对了」的例子。
三是历史回答无法追溯它当时用的是哪一版。我翻自己的历史对话时看到一条回答很奇怪,但完全不知道它是哪个版本的提示词产生的——是当时的提示词有问题,还是模型偶发,我判断不了。
四是提示词变长直接抬高了成本,而我没有察觉。我为了让回答更规范,往提示词里加了不少约束和示例。这些内容每一次调用都要作为输入发出去——而输入是计费的。我是在对账用量时才发现单次调用的输入长度比之前涨了不少。
所以四个改动:提示词存库并带版本号、新版本追加而不覆盖、建立固定问题集做改动前后的并排对比、每条回答记录它使用的提示词版本、记录每个版本的输入长度与单次成本。
这个模块最想说的一句话是:提示词是配置而不是代码,而配置需要版本、需要能回滚、需要有对比的办法。我一开始把它当成代码里的一个字符串,所以既没有版本也没有对比——每次改动都是不可逆的,而「是不是变好了」全靠我的主观印象。而印象是最不可靠的:我会挑那些证明自己改对了的例子。
提示词存库而不是留在代码里,代价是多一层配置管理。留在代码里有它的好处:跟着版本控制走、改动有记录、部署简单。但它有两个问题:一是改动即覆盖,我没法在运行时切回旧版本(要改代码重新发布);二是我调提示词的频率远高于发版的频率——几乎每天都在调。判据是「这个东西的改动频率和代码是不是同一个量级」:不是,那它就该按配置管理而不是按代码管理。而配置的三个基本要求是版本、回滚、以及能看出「现在用的是哪一版」。
新版本追加而不覆盖,接受存储上的冗余。覆盖最省空间、库里也干净。但提示词和代码不一样——代码改坏了可以从版本历史里找回来,而一段自然语言被覆盖之后就真的没了(我踩过这个坑,改了一版之后再也拼不出原来那段)。代价是库里会积累很多历史版本。判据是「这份内容重建的成本有多高」:一段调了很久的提示词,重建成本极高,那就绝对不能覆盖。
用固定问题集做人工并排对比,而不是做自动评分。自动评分(比如让模型给回答打分)听起来更系统。但我没有能力验证「评分本身是否可靠」——用一个我无法评估的评分器去评估另一个东西,等于把不确定性叠加了一层;而且评分本身也是一次收费调用。所以我选了最朴素的方式:固定问题集、并排放、人工看。它不高级,但每一步我都能解释。判据是「我能不能验证这个方法本身是对的」:能验证的才用。
判断标准在改动之前先写下来,这是这个模块里我认为最有价值的一条纪律。它不涉及任何技术,但它防的是一个我真实存在的倾向:看到结果之后为它找理由。我发现自己会不自觉地挑那些能证明「改对了」的例子,而忽略变差的那些。先写下「这次改动想解决的是哪一类问题」,就把判断的靶子固定住了。
踩过的坑一:改了一版提示词,两天后发现在代码问题上变差了,而原来那段已经找不回来了。我试着凭记忆重写,但怎么写都感觉不是原来那个效果。教训是:任何「一整段自然语言」性质的配置都必须版本化——它不像代码那样有明确的结构可以还原,改掉就是真的没了。
踩过的坑二:我以为自己在验证效果,实际上只是在找证据。每次改完随便问几个问题、觉得好像好一点就留下。而我问的问题每次都不一样、还往往是我刚好在想的那个。教训是:没有固定的对照就没有对比,而人在没有约束时会自然地挑选有利的证据——所以问题集要固定、判断标准要提前写。
踩过的坑三:往提示词里加约束和示例,把每次调用的成本抬上去了。我是对账用量时才发现的。教训是:提示词里的每一个字都会在每一次调用里被重复发送——它不是一次性成本,而是乘以调用次数的持续成本。所以「效果好一点但提示词长了很多」这种改动要算清账再决定。
没做的部分:没做提示词的自动化评测(用一批带标准答案的题目自动打分)。它需要一批可靠的标准答案,而这类题目的「标准答案」本身就很难定——尤其是开放性问题;而对于有确定答案的题目(比如代码能不能跑),自动判定是可行的,这是我认为下一步值得做的方向。取舍依据是「先做我能保证正确性的那部分」。
这个模块不适合报效果类数字,该报「机制是否具备」这些可核对的事实:提示词有版本号、新版本追加不覆盖、可切回任意旧版本、每条回答记录了它使用的版本号。这几条面试官可以当场追问实现,而且都能直接演示。
回滚能力用断言型用例:切换到新版本、再切回旧版本,断言两次的输出与对应版本一致、且历史回答上记录的版本号没有被改动。
可追溯性:随机取一条历史回答,断言能查到它当时使用的提示词版本的完整内容。这一条是「说不清是提示词问题还是模型偶发」这个坑的直接回归。
回归对比要报流程和问题集规模,而不是报分数:「问题集包含 N 个固定问题,覆盖概念解释、代码问题、长文本、边界刁难四类;每次改动前后各跑一遍、并排人工判断」。并诚实说明「判断是人工的,我没有自动评分能力」——这个诚实反而让整套流程更可信。
判断标准前置这一条可以报纪律:「每一版的备注里都写了『这一版想解决什么问题』,且备注的写入时间早于结果的产生时间」。时间顺序是可核对的,这让「先写标准」不只是一句口号。
成本变化必须报,这是最容易被忽略的一项:「各版本提示词的输入长度与单次调用成本」,用与计费一致的方式计算并与账单核对。报法是「某一版为了规范输出增加了约束与示例,单次输入长度上升了 X,对应单次成本上升了 Y」——把这个代价明确写出来,比只说「效果变好了」完整得多。
问题集的增长也值得报:「发现新问题时先补进问题集再改提示词,问题集从 N 个增长到 M 个」。它说明这套流程在真的运转,而不是建完就放着。
不要报什么:不要报「回答质量提升 N%」——我没有可靠的评分方式,这个数字编不出也验证不了。该报的是「版本可追加与回滚」「历史回答可追溯到具体版本」「固定问题集的规模与类型覆盖」「各版本的输入长度与单次成本对比」这几件可核对的事。
模型输出是我在这个项目里最晚才意识到「它是外部内容」的一样东西。我一直下意识地把它当成「我自己的系统产生的数据」,所以直接就渲染了。四个问题。
一是直接渲染输出,等于开了一条注入路径。我为了支持代码块和列表,把模型的输出按富文本渲染。而模型的输出内容是由提问决定的——我构造一个提问,让它输出一段带页面标记的内容,这段内容就被当作页面结构解析了。我是自己乱试的时候撞出来的:我让它「原样输出下面这段文本」,然后给了一段标记。这件事让我意识到:模型输出的内容我完全控制不了,它本质上和用户输入一样不可信。
二是输出里的链接可以指向任何地方。模型会在回答里给参考链接。而这些链接是它生成的、不是我校验过的——用户点了就被带到一个我完全不了解的地址。更麻烦的是链接文字和实际地址可以不一致(显示一个正规的名字、指向另一个地方)。
三是需要结构化结果时,解析失败就直接报错了。有个功能是从回答里提取几个字段。我要求模型按固定格式输出,然后解析。但模型并不总是严格按格式来——多一句解释、少一个符号,解析就抛异常,而用户看到的是一个报错,他完全不知道发生了什么。
四是用户输入可以覆盖我的设定。我把系统设定和用户问题拼在一起发过去。用户在提问里写「忽略之前的所有要求」,有时候确实能让模型不再遵守我的设定。这一点我没有能力完全解决,但至少要把两者的位置和标注分开、并且不依赖设定来做任何安全相关的判断。
所以四个改动:把模型输出当外部不可信内容、走受限渲染并先转义、输出中的链接做白名单与显式标注、结构化输出改为约束格式加解析失败重试、仍失败降级为纯文本、区分用户输入与系统设定的位置与标注。
这个模块最想说的一句话是:模型的输出是外部内容,不是我的系统生成的数据——而我一直下意识地把它当成后者,所以直接渲染了。它的内容由提问决定,而提问是用户写的,所以「模型输出」本质上是「用户输入经过一次变换」——既然用户输入不可信,那它的变换结果同样不可信。想清楚这个传递关系之后,转义、白名单、格式校验这几件事就都是自然的了。
把模型输出当作外部不可信内容处理,这是这个模块的全部出发点。直接渲染最省事,而且直觉上会觉得「这是我的系统调用接口拿回来的数据,应该是可信的」。但输出的内容由提问决定,而提问是用户写的——所以它本质上是「用户输入经过一次变换」。既然用户输入不可信,它的变换结果同样不可信。判据是「这段内容的实际决定者是谁」:是用户,那它就是外部输入。想清楚这个传递关系之后,转义、白名单、格式校验这几件事就都是自然的了——而在想清楚之前,我完全没觉得需要做这些。
受限渲染而不是完整富文本渲染。完整渲染能让回答的排版更好看(表格、嵌套结构)。但它意味着模型输出的任何标记都会被解析——而我控制不了模型输出什么。代价是复杂格式显示不出来。判据是「这个内容的产生者是否可信」:不可信,那渲染能力就必须受限。这一条和我在通知模板那类场景里的判断是同一个原则:不可信输入不能获得完整的渲染能力。
链接做白名单而不是全部禁止、也不是全部放开。全部禁止最安全,但模型给的参考链接确实经常是有用的,一律禁掉会削弱回答的价值;全部放开则可能把用户导向任意地址,而且链接文字和实际地址可以不一致。白名单加显式标注是一个中间选择:可信域名正常渲染、其余只显示纯文本地址让用户自己判断。代价是白名单需要维护、而且会漏掉一些其实无害的域名。判据是「用户能不能自己判断这个链接安全吗」:不能(因为显示文字可以骗人),那就必须由系统先做一层过滤。
结构化解析失败时降级为纯文本,而不是抛错。抛错最直接(格式不对就是失败)。但回答本身可能是有用的,只是格式不符合我的期望——把它整个丢掉是浪费;而用户看到一个报错完全不知道发生了什么。代价是要多一条降级路径、界面上要能处理两种展示形态。判据是「失败的时候有没有一个仍然有价值的退路」:有(纯文本展示),那就该降级而不是报错。
承认「用户输入覆盖系统设定」这件事我防不住,并据此确定了一条原则。我可以在提示词里反复强调「不要理会用户的其他要求」。但我实测发现这类约束并不可靠——而更重要的是,我不应该把安全性建立在一个不可靠的约束上。所以我的原则是:绝不依赖模型的设定来做任何安全相关的判断——权限校验、数据过滤、频率限制全部在服务端代码里做。我认为这一条比「怎么写更强的提示词」重要得多,因为它把安全性从「概率上有效」变成了「结构上有效」。
踩过的坑一:直接渲染模型输出,被自己构造的提问打出了注入。我让它「原样输出下面这段文本」然后给了一段标记,那段内容被当作页面结构解析了。教训是:我一直下意识地把模型输出当成「我自己系统产生的数据」,而它其实是外部内容——这个认知错误是根源,具体的转义、白名单都只是它的推论。
踩过的坑二:结构化解析动不动就失败,而我直接抛错了。模型多说一句解释、少一个符号,解析就挂。教训是:对一个「不保证严格遵守格式」的来源做解析,必须假设它会失败——而失败的处理不该是报错,应该是降级到一个仍然有用的形态。
踩过的坑三:我曾经想靠提示词来约束安全行为。比如在设定里写「不要输出任何链接」。后来实测发现这类约束在特定提问下会被绕过。教训是:不要把安全性建立在一个概率性有效的机制上——能在代码里做的判断,就不要交给提示词。
没做的部分:没做输出内容的语义级审核(判断回答是否包含不当内容)。它需要接一个内容检测能力,而检测的准确性我无法评估、误判会拦掉正常回答;而且流式输出下「边生成边审核」的时机也很难处理(一句话说到一半时无法判断)。取舍依据是「我无法评估其准确性的机制,接进来反而增加不确定性」——我做的是把结构层面的风险(注入、链接、格式)处理干净,而语义层面的判断留给更成熟的能力。
注入是断言型用例,也是这个模块最重要的一条:构造一个让模型输出页面标记的提问(比如要求它原样输出一段标记文本),断言这段内容以纯文本形式显示、没有被解析成页面结构、没有产生任何可执行内容。这条要保留成回归用例,因为渲染逻辑一改就可能被破坏。
代码块内容:让模型输出一段包含各种特殊字符的代码,断言它们全部按原样显示、页面结构完整。
链接白名单:让模型输出一个白名单外的地址,断言它只显示为纯文本、不可点击;输出白名单内的地址,断言渲染为链接、真实地址被显式标注、在新标签打开。
链接文字与地址不一致:构造一个「显示文字是常见网站名、实际地址是别处」的输出,断言界面上展示的是真实地址而不是只显示文字。
结构化解析的失败率值得报:「用固定的一批提问测试,约束格式后解析成功率为 X;失败的那部分经过一次重试后成功率为 Y;仍失败的降级为纯文本」。三个数字一起报,才说明这条链路是完整的——而不是只报「成功率很高」。
解析的宽容处理:构造「格式正确但前后多了说明文字」的输出,断言仍能被正确解析(这是最常见的一种偏差)。
重试的成本约束:断言解析失败的重试计入了调用次数统计与频率限制,不会绕过预算控制。
设定覆盖:我会诚实报「我构造了若干试图覆盖设定的提问,其中有部分确实让模型不再遵守设定」——并说明正是因为这个结果,我把所有安全相关的判断都放在了服务端代码里,而不是依赖提示词。报一个「没能完全防住」的结果,比声称防住了更可信。
不要报什么:不要说「输出是安全的」——语义层面的风险我没有处理。该报的是「构造注入型提问后输出以纯文本呈现」「白名单外链接不可点击、真实地址被标注」「结构化解析的成功率、重试后成功率与降级路径三个数」「试图覆盖设定的测试结果及我据此采取的原则」这几件可核对的事。
会话历史是我用得最久之后才发现问题的一块——因为它的问题都需要「积累了足够多的对话」才会出现。我自己用了一段时间、又灌了几千轮对话之后,四个问题一起来了。
一是会话列表越来越慢。我没有独立的会话概念,列表是「从消息表里找出所有会话、按会话分组、每组取最后一条」算出来的。对话积累起来之后这个查询明显慢下来——而它是打开应用第一眼就要看到的东西。这个问题和我在聊天项目里遇到的完全同源。
二是列表里全是「新会话」,我自己都找不到想要的那段对话。我给每个会话的默认标题就是「新会话」。积累了几十个之后,列表里是一整屏同名条目——我只能一个个点开找。而我要找的往往是「上周问过的那个问题」,靠时间也不好定位。
三是列表页很慢,因为它把长回答的正文也拉出来了。我的列表是取每个会话的最后一条消息作为摘要。而模型的回答可能很长(几千字的代码解释很常见)——列表拉二十个会话就意味着拉二十段长文本,然后在前端截断。传输的绝大部分内容是被丢掉的。
四是我不敢删除,因为删了就没了。历史对话里有一些我觉得以后可能有用的回答。而删除是不可逆的、也没有导出——于是我一条都不敢删,历史越积越多,列表越来越难找。问题的根源不是「删除功能不好用」,而是「我没有一个安全的退路」。
所以四个改动:建独立会话表并维护标题、摘要、更新时间、消息数、标题由首轮提问自动生成并允许改名、列表摘要单独存一份截断后的内容、提供导出,并把删除区分成删单条与删整个会话,另外补了按内容搜索历史对话。
这个模块最想说的一句话是:一个「历史记录」功能好不好用,取决于用户能不能找到他要的那一条——而不是取决于它存得有多全。我最初只关心「把对话都存下来」,所以标题全是「新会话」、没有搜索、也不敢删——结果是存得越全越没用。标题自动生成、按内容搜索、以及「可以放心删」这三件事,合起来才让历史真正可用。
建独立会话表,接受「同一份信息存两处」的冗余。从消息表算列表永远不会不一致(只有一份数据)。但它的耗时随消息总量增长,而会话列表是打开应用第一眼就要看到的东西。冗余的代价是要保证一致性——我的做法是把更新会话与消息落库放在同一个事务里。判据是「读的频率和底层数据的增长速度」:读得最频繁、底层一直在涨,那就必须把结果预先算好存下来。这和我在聊天项目里的会话列表是完全同一个判断,说明它是个通用模式。
会话标题用「截取首轮提问」而不是「让模型总结一句」。让模型总结出来的标题明显更好看、更概括。但那是一次额外的收费调用——而标题这个东西的价值是「能让我认出这段对话」,截取首轮提问的前若干字已经足够达到这个目的。代价是标题有时会很生硬(比如问题以「帮我看看」开头)。缓解办法是允许手动改名。判据是「这个改进值不值得一次额外调用的成本」:在我这个自费项目里不值得——而且我把这个取舍写清楚了,因为它是成本约束下的有意选择,不是没想到。
列表摘要单独存一份,而不是每次从正文截取。从正文截取不需要额外字段、也不会不一致。但模型回答经常几千字,列表拉二十个会话就意味着传二十段长文本、然后把绝大部分丢掉。代价是多一个字段、而且正文被编辑时摘要要同步更新。判据是「传输的数据里有多大比例会被用到」:比例极低,那就该在写入时把要用的部分单独存好。
提供导出,这是让「删除」变得可用的前提。我最初只想着「删除功能做好就行」。但真正的障碍不是删除不好用,而是我不敢删——历史里有些回答我觉得以后可能有用,而删除不可逆、又没有导出。结果是我一条都不敢删,历史越积越多、列表越来越难找。加了导出之后我才开始正常地清理历史。判据是「用户不用这个功能,是因为它不好用还是因为他不敢」:是不敢,那就该给他一个安全的退路,而不是把删除按钮做得更显眼。
删除单条消息时同步把它从后续上下文里去掉。只删展示最简单。但用户删除一条消息的意图通常包含「不要再基于它回答」——如果模型还「记得」,那这个删除是不完整的。判据是「用户执行这个操作时的完整预期是什么」:他的预期是「这条内容从此不存在」,那就要覆盖它的全部影响面。这一条和我在其他地方遇到的「撤销要还原全部影响」是同一个思路。
踩过的坑一:会话列表全是「新会话」,我自己都找不到想要的那段对话。积累几十个之后是一整屏同名条目。教训是:一个「历史记录」功能好不好用,取决于用户能不能找到他要的那一条——而不是取决于它存得有多全。我最初只关心「把对话都存下来」,结果是存得越全越没用。
踩过的坑二:列表页把长回答的正文全拉出来了。我是看接口返回体大小时发现的——一个列表请求的返回体大得离谱。教训是:列表接口要明确「这一页真正需要展示的是哪些字段」,而「顺手把整条记录返回」是很容易写出来的默认行为。
踩过的坑三:我自己不敢删历史,导致历史失去了作用。这个坑很有意思,因为功能是「完整」的(能删),但我不用它。教训是:当一个功能存在但没人用时,先问「是不好用还是不敢用」——这两种情况的解法完全不同。
没做的部分:没做语义检索(问法不同但意思一样也能搜到)。它需要向量存储和嵌入调用(又是一份持续的费用),而且检索质量的调优需要不断试。取舍依据是「当前量级下全文索引配合分词已经能满足『我记得我问过含某个词的问题』这个主要场景」——而语义检索解决的是「我记得意思但想不起用词」,这个场景在我自己的使用中出现频率不高。不过我能说清它的思路与代价。
会话列表耗时必须报清对话规模:「灌入数千轮对话后,会话列表接口耗时从 X 降到 Y,且改造后耗时与消息总量无关」。「与总量无关」是这个改动的核心收益,要明确写出来。
列表返回体大小是这个模块最直观的一个数字:报「一页 N 个会话的返回体大小从 X 降到 Y」,并说明原因是不再返回长回答的正文。这个数字可以直接在网络面板里看到,很容易核对。
标题的效果不适合用技术指标衡量,可以报一个操作层面的观察:「改造前列表里同名条目占绝大多数、需要逐个点开查找;改造后可直接从标题定位」。诚实说明这是定性观察而不是量化指标。
搜索用断言型用例:造一批含特定词的历史对话,断言能搜到、结果显示了命中片段、点击能定位到那条消息;并断言中文分词生效(搜两个字的词能命中长句)——这和论坛项目是同一套用例。
删除单条消息的完整性是踩坑的直接回归:删除某一轮的回答后继续提问,断言拼给模型的上下文里不包含它(直接检查请求体,不要靠观察回答)。
删除整个会话:断言会话与其全部消息都被删除、列表中不再出现、相关的统计数据也一并处理。
导出:断言导出的文本包含会话标题与逐轮问答、格式可读;并断言导出不受当前上下文裁剪的影响(导出的是全部历史,而不是模型看到的那部分)。后一条容易漏但很重要。
一致性:发一条新消息后,断言会话表的摘要、更新时间、消息数与消息表一致;人为让其中一步失败,断言事务回滚、两者不会不一致。
不要报什么:不要报「查找效率提升」——没有可靠的度量方式。该报的是「对话规模」「列表接口耗时与总量解耦」「一页返回体大小的对比」「中文分词后能搜到并定位到具体消息」「删除单条后上下文请求体中不含它」这几件带条件的、可核对的事。
没有匹配的内容,换个关键词试试。
项目拆解 · AI 对话工具(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据