怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

无实习这一档是给谁的 完全没有实习经历时,简历上唯一能写的就是自己做的项目。但「图书馆座位预约」「学生成绩管理」这类选题面试官一年看几百份,而硬套企业项目(微服务、分布式事务、消息中间件)会被一问就穿——他很清楚学生不可能有那种环境。这一档收的是复刻主流产品的核心链路、一个人真能做出来、技术栈朴素但有真实难点的项目。这一档的模块比企业档多——因为企业项目里你只被分到一个模块,而个人项目是从提示词管理到历史检索全都你自己写的,能讲的面本来就宽。下面六个模块彼此独立——只做过哪几个就只写哪几个。
怎么讲才不像课设 这个方向的坑在于「调个大模型接口」几乎没有门槛,所以只讲功能等于没讲。含金量在于你处理了调用它时真实存在的工程问题:流式输出怎么在中断和失败时不留下半截脏数据、对话变长之后上下文怎么裁剪才不丢关键信息、以及一次调用是要花钱的所以哪些请求根本不该发出去「我知道每次调用的成本、也知道我用了哪些手段把它降下来」这句话,比任何模型名词都有说服力。
只写你当场证得出来的 个人项目最容易穿的不是技术,是那些用来撑场面、但一追问就散的外部背书:「有几百个用户在用」会被问用户从哪来、怎么收集反馈;「开源项目」「有人 star」「参赛获奖」同样如此——面试时你拿不出来,说了等于给对方一个突破口正确的立足点只有一条:我没有真实流量,但我能复现问题、能讲清每个数字的来路。这一页的数字都来自我自己造的几十轮长对话、人为中断连接、以及按接口计费规则算出的单次调用成本——这些都是当场能复述的。
三条自检 一、你踩的坑能不能复现——能说清「我怎么中断、造多长的对话,它就必然出现」;二、能说出坑的根因,尤其是「短对话、网络正常时完全没问题」的那种;三、每个数字都能回答「这个数是怎么来的」(多长的对话、按什么计费规则算的、缓存命中率怎么统计的)。三条都有就能写。
项目背景设定 我做了一个自己每天在用的问答工具(多轮对话、会话列表、流式回答、可中断、历史可查),Spring Boot + MySQL + Redis + Vue调用第三方大模型的接口,一台服务器。这个项目的特殊之处是它花我自己的钱——所以成本从一开始就是一个必须处理的约束,不是「以后再优化」的东西。
数据和测试条件先说清楚 这是个人项目,没有真实用户量。数字来自我构造的条件:造了几十轮的长对话触发上下文超限、在回答生成到一半时人为断开连接或点中断复现半截数据、按接口的计费规则手算单次调用成本并与账单核对、用重复问题验证缓存命中这些我能当场复述,也能现场再跑一遍。
为什么这六块值得写 「调个接口拿回答」半小时就能跑通,真正的工程问题在下面这些地方。前三块是调用这条链路本身:回答是流式返回的、中断和失败会留下半截内容;对话越长成本越高、而且超过上下文长度上限会直接调用失败每次调用都在花钱,所以「哪些请求根本不该发出去」是必须回答的问题——这三件事在短对话、网络正常、不看账单的情况下全都不出现。后三块(提示词版本化、输出不可信、会话历史)是一个人做完这个产品绕不开、但几乎没人讲的部分提示词硬编码在代码里,改了就再也回不去、也说不清是改好了还是改坏了模型的输出是外部内容,直接渲染就是一个注入入口而对话历史本身就是一个需要分页、搜索、删除的数据集。六块都是我自己写的,所以每一块我都能讲到根因;这也是个人项目相比「企业里被分到一个模块」的唯一优势——面比较宽,别浪费。

模块一:流式输出与中断处理

  1. 流式输出与中断处理(边生成边落库避免中断丢内容 + 中断与失败都标记为不完整 + 不完整回答不参与后续上下文 + 前端渲染节流与代码块的稳定处理)★★★
    简历这样写 AI 对话工具(个人项目)(Spring Boot + Vue + 流式响应 + MySQL):回答采用流式输出,初版在生成完成后才落库,用户中途中断或网络断开时已显示的内容全部丢失且数据库无记录;改为边接收边追加落库,并把中断与失败的回答显式标记为不完整;不完整的回答不参与后续轮次的上下文拼接(此前会把半截内容当作正常历史发给模型,导致后续回答被带偏);前端按帧节流渲染而非每个片段都触发一次更新(长回答下逐片段更新会造成明显卡顿),并对未闭合的代码块做临时补全,消除了生成过程中格式反复跳变的问题;中断请求会真正终止上游调用而不仅是前端停止显示——后者仍在持续产生费用。以上问题均通过人为中断与断网复现。
    展开完整拆解
    为什么要这么设计

    流式输出跑通本身不难:服务端把上游返回的片段一段段转发给前端,前端追加显示正常情况下体验很好。四个问题都是我人为中断或断网之后才出现的。

    一是中断之后已显示的内容全部消失。我的落库是在生成完成时做的。用户看着回答一点点出来、看到一半觉得够了点了中断——刷新页面之后这条回答完全不见了因为它从来没有被存下来。网络断开的情况一样。用户的感受是「刚才那段挺有用的内容白看了」。

    二是半截回答被当成正常历史发给了模型。我后来改成了边接收边落库,但没有标记它是不完整的结果是下一轮对话时,我把这段半截内容当作助手的正常回答拼进了上下文——模型看到一段没说完的话,后续回答明显被带偏了,有时候会接着那半句继续说。这个问题很隐蔽,我一开始以为是模型不稳定。

    三是长回答时前端明显卡顿。我在每收到一个片段就触发一次渲染。片段是很碎的,一个长回答会触发上千次更新——而每次更新都要重新解析整段内容的格式(加粗、列表、代码块),在长回答后半段已经很卡了。

    四是生成过程中格式一直在跳变。代码块的起始标记先到、结束标记还没到,渲染器把后面所有内容都当成了代码;下一个片段到了之后又变回来。整段回答在生成过程中不停地在「代码块」和「普通文本」之间闪。

    另外还有一个我差点没发现的问题:前端点中断只是停止了显示,而服务端到上游的调用还在继续——那部分内容我照样付费了。

    所以四个改动:改为边接收边追加落库中断与失败显式标记为不完整且不参与后续上下文前端按帧节流渲染对未闭合的代码块做临时补全,另外让中断真正终止上游调用

    这个模块最想说的一句话是:流式输出把「一次请求一次响应」变成了「一段持续的过程」,而过程是可以在任意一点断掉的——所以每一个中间状态都必须是可解释的。我最初的实现只考虑了「顺利跑完」这一种结局,所以中断之后留下的是一个既没存下来、又没有标记、还会污染后续对话的烂摊子。

    整体链路
    发起(先把记录建好,再开始流) │ ├─ 用户提问 → 立即落库用户消息 → 建一条空的助手回答记录(状态:生成中) │ 先建记录的好处:后面任何时刻断掉,都有地方可以追加与标记 │ └─ 服务端调用上游,开启流式接收 流式转发与落库(边收边存) │ ├─ 每收到一段 → 转发给前端 → 同时追加到那条回答记录 │ 生成完成才落库的后果(我踩过) │ 用户中断或网络断开 → 已显示的内容全部消失 │ 因为它从来没有被存下来,刷新页面就没了 │ ├─ 追加落库要做批量或节流,不要每个片段写一次数据库 │ 片段很碎,逐个写库的写入次数会非常大 │ └─ 正常结束 → 记录状态改「已完成」,写入本次消耗的用量 中断与失败(每种结局都要可解释) │ ├─ 用户主动中断 │ 前端发中断请求 → 服务端终止对上游的调用 → 记录标记「已中断」 │ 只在前端停止显示的后果 │ 服务端到上游的调用还在继续 → 那部分内容照样付费 │ 这一点我差点没发现,是对账用量时才看出来的 │ ├─ 网络断开 / 上游报错 │ 记录标记「不完整」并保留已生成的部分 │ 不保留的后果:用户看到的内容凭空消失,体验最差 │ ├─ 界面上要明确区分「已完成 / 已中断 / 生成失败」 │ 不区分的后果:用户不知道这段话是说完了还是断了 │ └─ 提供「继续生成」或「重新生成」入口 重新生成要产生一条新记录,不要覆盖原来的(原来的可能有用) 不完整回答不参与后续上下文(这是最隐蔽的一条) │ ├─ 拼接上下文时跳过标记为不完整的助手回答 │ ├─ 不跳过的后果 │ 把半截内容当作助手的正常回答发给模型 │ 模型看到一段没说完的话 → 后续回答被带偏 │ 有时会接着那半句继续说,用户完全不知道为什么 │ 这个问题很隐蔽,我一开始以为是模型不稳定 │ └─ 如果用户明确要求「接着上面继续」,再显式把它带上并说明 也就是「默认不带,需要时显式带」 前端渲染(流式渲染有两个专门的坑) │ ├─ 按帧节流渲染,不要每个片段都触发一次更新 │ 逐片段更新的后果 │ 长回答会触发上千次更新 │ 每次更新都要重新解析整段格式,后半段明显卡顿 │ ├─ 未闭合的代码块要临时补全再渲染 │ 不补的后果 │ 代码块起始标记到了、结束标记没到 │ 渲染器把后面所有内容都当成代码,下一片段又变回来 │ 整段回答在生成过程中不停地闪 │ ├─ 自动滚动到底部,但用户手动上滑后停止自动滚动 │ 不停的后果:用户想回看前面的内容,一直被拽回底部 │ └─ 生成中要禁用发送按钮或支持排队,不要允许并发提问
    分步拆解
    1. 提问时先落库用户消息、并建一条状态为「生成中」的空回答记录。先建记录的好处是后面任何时刻断掉,都有地方可以追加与标记。
    2. 每收到一段就追加落库,不要等生成完成。完成才落库的后果是用户中断或断网时已显示的内容全部消失,刷新页面就没了。
    3. 追加落库要批量或节流。片段很碎,逐个写库的写入次数会非常大。
    4. 正常结束时写入本次消耗的用量。这是后面做成本统计和对账的基础。
    5. 用户中断必须真正终止对上游的调用。只在前端停止显示的后果是服务端到上游的调用还在继续,那部分内容照样付费——这一点我是对账用量时才发现的。
    6. 网络断开或上游报错时要保留已生成的部分并标记不完整。不保留的后果是用户看到的内容凭空消失,体验最差。
    7. 界面上必须区分「已完成 / 已中断 / 生成失败」。不区分的后果是用户不知道这段话是说完了还是断了。
    8. 要提供「继续生成」或「重新生成」入口。重新生成要产生一条新记录,不要覆盖原来的——原来的那半截可能有用。
    9. 拼接上下文时必须跳过标记为不完整的助手回答。不跳过的后果是模型看到一段没说完的话,后续回答被带偏、有时会接着那半句继续说——这个问题非常隐蔽,我一开始以为是模型不稳定。
    10. 如果用户明确要求「接着上面继续」,再显式带上那段并说明。原则是默认不带,需要时显式带。
    11. 前端要按帧节流渲染。逐片段更新的后果是长回答触发上千次更新,每次都要重新解析整段格式,后半段明显卡顿。
    12. 未闭合的代码块要临时补全再渲染。不补的后果是整段回答在生成过程中不停地在「代码块」和「普通文本」之间闪。
    13. 其他成对的格式标记(加粗、行内代码)也要处理未闭合的情况。思路和代码块一样。
    14. 自动滚动到底部,但用户手动上滑后要停止自动滚动。不停的后果是用户想回看前面的内容,一直被拽回底部。
    15. 生成中要禁用发送或支持排队,不要允许并发提问。并发提问会让上下文顺序错乱,而且是双倍的费用。
    关键决策与取舍

    边接收边落库,而不是完成后一次性落库。完成后落库的写入次数最少、实现也最简单。但它的前提是「一定能顺利完成」——而流式输出恰恰是可以在任意一点断掉的代价是写入次数增加,所以我做了批量与节流(攒够一定量或一定时间才写一次)。判据是「中间断掉时用户已经看到的内容值不值得保留」:值得——用户看着内容一点点出来,它在他眼里已经是「已经得到的东西」了,凭空消失是最糟的体验。

    不完整的回答默认不参与上下文,而不是照常拼进去。照常拼进去实现最简单(不用区分状态)。但半截内容会把模型带偏——它会试图接着那半句说下去,而用户完全不知道为什么回答变奇怪了代价是要维护回答的完整性状态、拼接时要过滤判据是「这段数据作为输入是否可信」:不可信的输入不该混进正常流程。而「默认不带、需要时显式带」这个模式让特殊需求(接着继续写)仍然可以满足。

    中断要终止上游调用,而不是只停前端显示。只停前端实现最省事(一行代码)。但服务端到上游的调用还在跑,那部分内容我照样付费——而这是这个项目里唯一一个「不修就一直在漏钱」的问题判据是「这个操作背后还有没有正在持续消耗的资源」:有,那就必须真正把它停掉。这一点我是在对账用量时才发现的,说明它很容易被忽略。

    前端按帧节流而不是逐片段渲染。逐片段渲染的响应最即时。但片段的到达频率远高于屏幕刷新频率,多余的更新对用户完全不可见,却实实在在地消耗了性能——而每次更新都要重新解析整段格式,成本随回答变长而增加判据是「更新频率有没有超过用户能感知的上限」:超过了,那多出来的部分就是纯浪费。

    踩过的坑一:中断之后已显示的内容全部消失。因为落库在生成完成时才做。教训是:流式输出把「一次请求一次响应」变成了「一段持续的过程」,而过程可以在任意一点断掉——所以每一个中间状态都必须是可解释、可保存的我最初的实现只考虑了「顺利跑完」这一种结局。

    踩过的坑二:半截回答污染后续对话,而我以为是模型不稳定。现象是接下来几轮的回答变得很奇怪、有时候接着上一句没说完的话继续。我怀疑过模型、怀疑过参数,查了很久才想到去看自己拼的上下文长什么样——一眼就看到了那段半截的内容教训是:模型表现异常时,先检查自己送进去的输入——很多时候不是模型的问题,是输入里有脏东西。这个排查顺序帮我省了很多时间。

    踩过的坑三:前端点中断但费用还在产生。我是把接口用量和自己的账单对照时发现数字对不上的。教训是:任何「取消 / 停止」的操作都要确认它停掉的是整条链路,而不只是你眼前这一段——尤其当链路的另一端在花钱。

    没做的部分:没做「断点续传式的继续生成」(从中断处让模型精确接着往下写)。它需要把已生成的部分作为提示的一部分回传,而模型并不保证从断点无缝衔接,拼出来的内容经常有重复或跳跃我做的是「重新生成」并保留原记录——取舍依据是「无法保证质量的功能不如不做,但要说清它难在哪」。

    数字是怎么测的

    中断保留内容是断言型用例,也是这个模块最核心的一条:在生成到一半时点中断,断言数据库里存有已生成的部分、状态为「已中断」、刷新页面后内容仍在;断网同理。

    上游调用是否真的被终止,要用接口用量来验证:「在生成到一半时中断,对比中断前后接口报告的用量」。报法是「修复前中断后用量仍继续增长、修复后停止增长」——这个数字来自接口方的用量统计,是可核对的外部证据,比自己打日志更有说服力。

    上下文污染要用可复现的方式验证:构造一条被中断的半截回答,然后继续提问,断言拼给模型的上下文里不包含这段内容(直接检查请求体,不要靠观察回答质量——那不可复现)。

    渲染性能要报回答长度与更新次数:「一条约 N 字的回答,逐片段渲染触发约 M 次更新、按帧节流后降到 K 次」,并报后半段的渲染耗时对比更新次数比「感觉流畅了」具体得多。

    代码块闪烁:断言生成过程中未闭合的代码块被临时补全、整段内容不出现「代码块与普通文本反复切换」。这条可以录屏逐帧看,也可以断言渲染前的补全逻辑生效。

    自动滚动:断言用户手动上滑后自动滚动停止、滑回底部后恢复。

    并发提问:断言生成中无法再次提交,或提交后进入排队而不是并发调用。

    不要报什么:不要报「回答质量提升」——那取决于模型,不是我的工作。该报的是「中断后已生成内容仍在库中且状态正确」「中断后接口用量停止增长」「上下文请求体中不含不完整回答」「某长度回答的渲染更新次数从 M 降到 K」这几件可核对的事。

    面试追问
    Q:流式输出就是把片段转发给前端,还有什么可做的? A:顺利跑完那条路径确实没什么可做的,我踩的三个坑全在「中途断掉」上。第一个:中断之后已显示的内容全部消失——因为我的落库是在生成完成时做的,用户看着回答一点点出来、看到一半觉得够了点了中断,刷新页面这条回答就完全不见了教训是:流式输出把「一次请求一次响应」变成了「一段持续的过程」,而过程可以在任意一点断掉——所以每个中间状态都必须可解释、可保存。改法是提问时先建一条状态为「生成中」的空回答记录,然后边收边追加(追加要批量节流,片段很碎,逐个写库的写入次数会非常大)。第二个坑最隐蔽:半截回答污染了后续对话。我改成边收边存之后没有标记它是不完整的结果下一轮我把这段半截内容当作助手的正常回答拼进了上下文——模型看到一段没说完的话,后续回答明显被带偏,有时候会接着那半句继续说我怀疑过模型、怀疑过参数,查了很久才想到去看自己拼的上下文长什么样,一眼就看到了那段半截内容。教训是:模型表现异常时先检查自己送进去的输入——很多时候不是模型的问题,是输入里有脏东西。所以现在不完整的回答默认不参与上下文,用户明确要求「接着继续」时才显式带上第三个坑是花钱的:我前端点中断只是停止了显示,服务端到上游的调用还在继续,那部分内容我照样付费了。我是把接口用量和账单对照时发现数字对不上才注意到的。教训是:任何「取消 / 停止」的操作都要确认它停掉的是整条链路,而不只是你眼前这一段——尤其当链路的另一端在花钱。
    Q:前端把片段追加显示就行了,为什么还要做渲染节流? A:因为片段的到达频率远高于屏幕能刷新的频率,多出来的更新用户完全看不见,但性能是实实在在消耗掉了。我最初是每收到一个片段就触发一次渲染,一条长回答会触发上千次更新而每次更新都要重新解析整段内容的格式(加粗、列表、代码块),解析成本随回答变长而增加——所以长回答的后半段明显卡顿。改成按帧节流(一帧内只渲染一次最新内容)之后更新次数下降了一个量级,而用户看到的效果完全一样判据是「更新频率有没有超过用户能感知的上限」:超过了,多出来的部分就是纯浪费。流式渲染还有第二个专门的坑,我觉得更有意思:格式在生成过程中反复跳变。具体是代码块的起始标记先到、结束标记还没到,渲染器就把后面所有内容都当成了代码;下一个片段到了之后又变回来——整段回答在生成过程中不停地在「代码块」和「普通文本」之间闪。解法是渲染前检查成对标记是否闭合,未闭合的临时补全一个(加粗、行内代码同理)。这个坑的根因是:流式内容在任意一个时刻都可能是「语法上不完整」的,而渲染器是按完整文档设计的——所以要在它们之间加一层修补。还有一个体验细节:自动滚动到底部是必要的,但用户手动上滑之后必须停止自动滚动,否则他想回看前面的内容会一直被拽回底部。

模块二:上下文窗口的裁剪策略

  1. 上下文窗口的裁剪策略(超限直接失败改为按预算裁剪 + 保留首轮与最近若干轮 + 用真实计数而非字数估算 + 为回答预留输出空间)★★★
    简历这样写 上下文窗口管理(Spring Boot + 分词计数 + MySQL):初版把整个会话历史全部拼进请求,对话轮次增多后出现两个问题——超过上下文长度上限导致调用直接失败、以及每轮请求都携带全部历史使成本随轮次线性增长;改为按预算裁剪:先用与计费一致的方式计算真实长度(此前按字数估算,中英文混排与代码块的偏差很大,导致要么仍然超限要么白白浪费预算),再按保留首轮设定 + 最近若干轮的策略取舍,并为模型输出预留固定空间(此前只按输入算预算,回答长时仍会超限);被裁掉的中间轮次在界面上明确标注,避免用户以为模型「忘记」是产品缺陷;同时对单轮超长输入做提前拦截与提示,不再发出注定失败的请求。改造后长对话不再调用失败,单轮成本不随对话轮次持续增长。
    展开完整拆解
    为什么要这么设计

    多轮对话的实现看起来很自然:把之前的问答全部拼成一个列表,连同新问题一起发过去短对话完全没问题,我用了一段时间之后才撞上四个问题。

    一是聊到几十轮之后调用直接失败。报错说超过了上下文长度上限。我一开始完全没意识到有这个限制——而它的表现是「用得越久越容易坏」,越是重度使用的会话越先坏。

    二是成本随对话轮次线性增长。我每一轮都把全部历史发过去。第一轮只发一个问题,第二十轮要发二十轮的全部内容——而输入是计费的同样一个问题,在长会话里问和在新会话里问,费用差很多倍。我是对账用量时才发现自己在为大量重复的历史反复付费。

    三是我按字数估算长度,估得不准。我最初用「字符数除以一个系数」来估算,然后按估算值裁剪。结果中英文混排和代码块的偏差非常大——有时候估少了、裁完还是超限;有时候估多了、明明还有空间却把有用的历史裁掉了。

    四是只按输入算预算,回答长的时候还是超限。我把输入裁到刚好不超上限,但模型的输出也要占同一个窗口——回答一长就又超了,而这次失败发生在生成过程中,用户已经看到半截回答了。

    所以四个改动:超限直接失败改为按预算主动裁剪裁剪策略为保留首轮设定加最近若干轮用与计费一致的方式计算真实长度而不是按字数估算为模型输出预留固定空间,另外补了单轮超长输入的提前拦截

    这个模块最想说的一句话是:上下文窗口是一个必须主动管理的预算,而不是一个「到了再说」的限制。我原来的心态是「先全都发过去,超了再处理」,而超了的表现是调用直接失败——也就是说我把一个可以提前规避的问题留成了运行时错误而且这个预算同时决定了成本:管好它既是为了不出错,也是为了少花钱。

    整体链路
    先算清预算(不能按字数估) │ ├─ 上下文窗口总量 = 输入 + 输出 共享的上限 │ ├─ 预算划分 │ 给输出预留固定空间(否则回答一长就超限) │ 剩下的才是输入可用的额度 │ 只按输入算预算的后果 │ 输入裁到刚好不超上限,但输出也占同一个窗口 │ 回答一长就又超了,而这次失败发生在生成过程中 │ 用户已经看到半截回答了 —— 体验比一开始就失败更差 │ └─ 长度计算要用与计费一致的方式,不要按字数估 按「字符数除以系数」估算的后果 中英文混排与代码块的偏差非常大 估少了 → 裁完还是超限 估多了 → 明明还有空间却把有用的历史裁掉了 裁剪策略(保留什么、丢什么) │ ├─ 必须保留:首轮的设定(系统提示 / 角色设定) │ 它决定了整个会话的行为方式,丢了整个对话就变味了 │ ├─ 优先保留:最近若干轮 │ 对话的相关性通常集中在最近几轮 │ ├─ 优先丢弃:中间较早的轮次 │ ├─ 不完整的回答直接跳过(见模块一) │ └─ 裁剪要按轮次成对进行(问和答一起丢) 只丢答不丢问的后果:模型看到一个没有回应的提问,容易被带偏 被裁掉之后要告诉用户 ├─ 界面上标注「较早的对话已省略」 ├─ 不标注的后果 │ 用户发现模型「忘记」了前面说过的话,以为是产品缺陷 │ 而这其实是窗口限制下的必然结果,说清楚他就理解了 └─ 可以提供「新建会话」的引导(长会话本身成本也高) 单轮超长输入的提前拦截 ├─ 用户一次粘贴了极长的内容 → 提前判断是否超过输入额度 ├─ 超过则直接提示并给出建议(分段提问 / 精简内容) └─ 不拦截的后果:发出一个注定失败的请求,白等一轮还可能计费 成本随轮次增长的抑制 ├─ 裁剪本身就把「每轮携带全部历史」变成了「携带固定上限的历史」 │ 成本从随轮次线性增长变为有上界 ├─ 我是对账用量时才发现自己在为大量重复的历史反复付费 └─ 长会话给出「新建会话」引导,也是成本手段 可观测 ├─ 记录每轮的输入长度、输出长度、是否发生裁剪、裁掉了几轮 ├─ 这些数据是后面调整预算划分与保留轮数的依据 └─ 裁剪比例长期很高 → 说明保留轮数设得太大或用户会话太长
    分步拆解
    1. 先明确上下文窗口是输入与输出共享的上限。只按输入算预算的后果是回答一长就超限,而这次失败发生在生成过程中,用户已经看到半截回答了——比一开始就失败体验更差。
    2. 必须为输出预留固定空间。预留多少要按实际回答长度的分布来定,留太少会截断回答、留太多会浪费输入额度。
    3. 长度计算要用与计费一致的方式,不要按字数估算。按字符数除系数估算的后果是中英文混排与代码块偏差很大:估少了裁完还超限,估多了白白浪费预算。
    4. 首轮的设定必须保留。它决定了整个会话的行为方式,丢了整个对话就变味了。
    5. 优先保留最近若干轮。对话的相关性通常集中在最近几轮,而不是最早几轮。
    6. 裁剪要按轮次成对进行,问和答一起丢。只丢答不丢问的后果是模型看到一个没有回应的提问,容易被带偏。
    7. 不完整的回答直接跳过。和模块一的处理一致。
    8. 被裁掉之后要在界面上标注「较早的对话已省略」。不标注的后果是用户发现模型「忘记」了前面说过的话,以为是产品缺陷——而这其实是窗口限制下的必然结果,说清楚他就理解了。
    9. 长会话要给出「新建会话」的引导。它既解决记忆问题,也是成本手段(长会话每轮都要携带更多历史)。
    10. 单轮超长输入要提前拦截。不拦截的后果是发出一个注定失败的请求,白等一轮还可能计费。
    11. 拦截时要给出可操作的建议。「分段提问」「精简内容」比「输入过长」有用得多。
    12. 要记录每轮的输入长度、输出长度、是否裁剪、裁掉几轮。这些数据是调整预算划分与保留轮数的依据,不记录就只能凭感觉调。
    13. 裁剪比例长期很高要当成信号。它说明保留轮数设得太大,或者应该更早引导用户新建会话。
    14. 要意识到裁剪同时解决了成本问题。它把「每轮携带全部历史」变成了「携带固定上限的历史」——成本从随轮次线性增长变为有上界。
    关键决策与取舍

    主动按预算裁剪,而不是「先全发过去、超了再处理」。后者实现最简单(不用算长度、不用裁)。但它把一个可以提前规避的问题留成了运行时错误——而且是「用得越久越容易坏」,越是重度使用的会话越先坏代价是要引入长度计算和裁剪逻辑判据是「这个错误能不能在发出请求之前就避免」:能,那就不该留到运行时。而且裁剪顺带解决了成本问题,这是我一开始没意识到的额外收益。

    用与计费一致的方式计算长度,而不是按字数估算。估算实现简单、也不需要额外依赖。但偏差在中英文混排和代码块上非常大——估少了裁完还超限(等于没裁),估多了把有用的历史白白裁掉代价是要引入一个计数的实现、并且它要和计费口径对齐判据是「估算的偏差会不会导致功能失效」:会(仍然超限),那就必须用真实计数。而且用真实计数之后,我才能准确算出每轮的成本——这两件事是同一个基础。

    裁剪策略选「保留首轮设定 + 最近若干轮」,而不是更复杂的方案。更聪明的做法是把较早的对话摘要压缩成一段简短的描述再带上但那意味着每次裁剪都要额外调用一次模型来做摘要——多一次调用就是多一份费用和延迟,而且摘要本身可能丢掉关键信息或引入错误我选了简单策略,但把它的局限写清楚了:较早的具体细节确实会丢取舍依据是「摘要的成本与不确定性,是否值得换回那部分记忆」——在我的使用场景下不值得,而且我能说清完整方案是什么。

    被裁掉要告诉用户,而不是静默处理。静默处理界面更干净。但用户会发现模型「忘记」了前面说过的话,而他不知道原因,只会觉得产品不行一行提示的成本几乎为零,却把一个「产品缺陷」变成了「可以理解的限制」。这一条我觉得是很划算的产品细节。

    踩过的坑一:聊到几十轮后调用直接失败,我完全没意识到有窗口上限。而它的表现很特别:用得越久越容易坏,越是我自己重度使用的那个会话越先坏教训是:调用第三方接口时,要先把它的限制(长度上限、频率限制、单次大小)读一遍并在代码里显式处理——而不是等它报错了再回头看文档。我现在接任何外部接口都会先列一遍它的约束。

    踩过的坑二:按字数估算长度,裁完还是超限。我一开始觉得「差不多就行」。但代码块的偏差大到让估算完全失效教训是:当一个估算值被用来做「是否超限」这种硬判断时,它的偏差就是功能的失效率——这种地方不能用估算。

    踩过的坑三:只按输入算预算,输出超限导致回答被截断在半路。用户已经看到半截回答了,这比一开始就失败更糟。教训是:共享的资源要按「所有使用方」一起算预算,不能只算自己看得见的那一部分。

    没做的部分:没做基于向量检索的长期记忆(把全部历史存起来、按相关性检索出最相关的几段带上)。它是解决「长对话记忆」的正规做法,但需要向量存储、嵌入调用(又是一份费用)和检索质量的调优取舍依据是「它引入的组件和不确定性远超我这个项目的需要」——不过我能说清它的思路和要解决的问题,这比假装自己做过要好。

    数字是怎么测的

    调用失败率是这个模块最直接的指标:造一个几十轮的长对话,断言改造前在第 N 轮左右开始持续调用失败、改造后可以继续对话报法要说明「N 轮」是在什么样的对话内容下(每轮多长、有没有代码块),因为轮数上限完全取决于内容长度。

    长度计算的准确性要和计费口径对照:「用真实计数方式算出的输入长度,与接口返回的用量报告对比,偏差在可接受范围内」;再报「按字数估算时的偏差有多大」作为对照——这个对照直接说明了为什么不能用估算。

    成本随轮次的变化是这个模块最有说服力的数字:报「同一个问题在第 1 轮与第 20 轮提问时的输入长度与费用」,改造前随轮次线性增长、改造后稳定在预算上限附近费用按接口的计费规则手算,并与账单核对——要说明是手算的还是账单上的,不要混为一谈。

    输出预留的有效性:构造一个会产生很长回答的提问,断言不会在生成过程中因超限而中断。

    裁剪策略的正确性用断言型用例:断言首轮设定始终被保留、最近若干轮被保留、被裁的是中间轮次、且问答成对被裁而不是只裁一半。

    用户提示:断言发生裁剪时界面出现「较早的对话已省略」的标注。

    超长输入拦截:粘贴一段超过输入额度的内容,断言在发出请求之前就被拦截并给出建议,不产生任何调用。

    可观测数据:断言每轮都记录了输入长度、输出长度、是否裁剪、裁掉几轮。这些是后续调参的依据。

    不要报什么:不要报「上下文利用率」这种自造指标——没有公认口径。该报的是「长对话从第 N 轮起失败改为可继续」「真实计数与用量报告的偏差、以及按字数估算的偏差对照」「同一问题在第 1 轮与第 20 轮的输入长度与费用对比」「超长输入在发出前被拦截」这几件可核对的事。

    面试追问
    Q:多轮对话把历史全发过去不就行了? 这不是最简单的做法吗? A:我一开始就是全发过去,用了一段时间之后撞上两个问题,一个是坏、一个是贵。坏的那个是:聊到几十轮之后调用直接失败,报错说超过了上下文长度上限——我一开始完全没意识到有这个限制,而它的表现很特别:用得越久越容易坏,越是我自己重度使用的那个会话越先坏教训是:调用第三方接口时要先把它的限制(长度上限、频率限制、单次大小)读一遍并在代码里显式处理,而不是等它报错了再回头看文档。贵的那个是:我每一轮都把全部历史发过去,而输入是计费的——第一轮只发一个问题,第二十轮要发二十轮的全部内容,同样一个问题在长会话里问和在新会话里问费用差很多倍。我是对账用量时才发现自己在为大量重复的历史反复付费。改法是按预算主动裁剪:先给输出预留固定空间(因为窗口是输入和输出共享的,我一开始只按输入算预算,结果回答一长又超了,而这次失败发生在生成过程中、用户已经看到半截回答,比一开始就失败更糟),剩下的额度按「必须保留首轮设定 + 优先保留最近若干轮 + 优先丢弃中间轮次」来裁,而且要按轮次成对裁(只丢答不丢问的话,模型看到一个没有回应的提问容易被带偏)。还有一个必须做对的基础:长度要用与计费一致的方式真实计算,不能按字数估。我最初用「字符数除以系数」估,中英文混排和代码块的偏差非常大——估少了裁完还超限(等于没裁),估多了明明还有空间却把有用的历史裁掉了当一个估算值被用来做「是否超限」这种硬判断时,它的偏差就是功能的失效率。
    Q:把较早的对话做个摘要带上,不比直接裁掉更好吗? A:摘要确实是更正规的做法,我评估之后没做,但我想说清它的代价,以及我选简单策略之后是怎么弥补的。摘要的代价有三个:每次裁剪都要额外调用一次模型来生成摘要——多一次调用就是多一份费用和延迟摘要本身可能丢掉关键信息,甚至引入原文里没有的内容而且摘要是否准确我很难自动验证取舍依据是「摘要的成本与不确定性,是否值得换回那部分记忆」——在我的使用场景下(大多是几轮内就问完的技术问题)不值得。所以我选了「保留首轮设定 + 最近若干轮」这个简单策略,但做了两件事来弥补它的局限。一是把局限明确告诉用户:发生裁剪时界面上标注「较早的对话已省略」。不标注的后果是用户发现模型「忘记」了前面说过的话,而他不知道原因,只会觉得产品不行——一行提示的成本几乎为零,却把一个「产品缺陷」变成了「可以理解的限制」二是给长会话「新建会话」的引导,因为它既解决记忆问题、也是成本手段(长会话每轮都要携带更多历史)另外我把裁剪相关的数据都记了下来:每轮的输入长度、输出长度、是否发生裁剪、裁掉了几轮——这些是后面调整预算划分和保留轮数的依据,不记录就只能凭感觉调而「裁剪比例长期很高」这个信号说明保留轮数设得太大,或者应该更早引导用户新建会话。更完整的方案我也知道是什么:把全部历史存起来、用向量检索按相关性取出最相关的几段带上。它需要向量存储、嵌入调用(又是一份费用)和检索质量的调优——我认为能说清它的思路和要解决的问题,比假装自己做过要好。

模块三:调用成本控制与失败降级

  1. 调用成本控制与失败降级(相同问题命中缓存不重复调用 + 按用量记账与预算上限 + 重试要区分可重试与确定性失败 + 超时与失败的可用降级出口)★★★
    简历这样写 调用成本控制与降级(Spring Boot + Redis + MySQL):这是一个自费项目,因此把成本作为硬约束:对相同问题(规范化后取指纹)在有效期内命中缓存直接返回,不重复调用上游;逐次记录调用用量并折算费用入库,配合日预算上限——接近上限时提示、超出则暂停调用而不是继续产生费用;重试策略从统一重试改为区分可重试与确定性失败(超时、频率限制类退避重试;参数错误、内容被拒类不重试,因为重试必然再次失败且仍可能计费);上游超时或失败时提供明确的失败态与重试入口,并在连续失败时降级为「暂不可用」提示而非无限转圈;同时对单用户频率做限制,避免脚本或误操作在短时间内产生大量调用。改造后重复问题不再重复付费,费用有上限、可按天核对。
    展开完整拆解
    为什么要这么设计

    这个项目和我做过的其他东西有一个本质区别:每一次调用都在花我自己的钱所以「成本」不是一个可以留到以后优化的事项,它从第一天就是约束。四个问题都是我看账单之后才处理的。

    一是相同的问题反复调用、反复付费。我自己在调试的时候会把同一个问题问很多遍(改了提示词、改了参数、或者只是想再看一遍回答)。每一遍都是一次完整的调用和费用而其中很大一部分的输入完全一样,回答也没有必要不同。

    二是我不知道钱花在哪了。我一开始只有一个总账单,没有逐次记录用量结果是月底看到一个数字,但完全无法定位是哪些调用贵、是输入贵还是输出贵、有没有异常的调用更麻烦的是我发现账单比我预期高不少,却查不出原因。

    三是失败之后无脑重试,把钱重复烧掉。我加了统一的重试(失败就重试几次)。但有些失败是确定性的——参数不对、内容被上游拒绝——这类失败重试一百次也是失败,而每次尝试仍然可能计费我等于在为注定失败的请求反复付钱。

    四是没有任何上限,出问题时会一直漏。我在调试一个循环的时候不小心让它连续发了很多次调用——而系统没有任何机制阻止它,直到我自己发现。如果我睡着了,损失会大得多。

    所以四个改动:相同问题命中缓存直接返回逐次记录用量并折算费用入库重试区分可重试与确定性失败加日预算上限与单用户频率限制,另外把失败做成明确的降级出口

    这个模块最想说的一句话是:当一个操作会产生真实费用时,「哪些请求根本不该发出去」这个问题的优先级高于「怎么让请求更快」。我原来做优化的思路都是「怎么让它更快、更稳」,而这个项目让我第一次认真想「怎么让它别发」——缓存、确定性失败不重试、预算上限、频率限制,四件事全都是在减少调用次数,而不是优化单次调用。

    整体链路
    调用前:能不发就不发 │ ├─ 问题规范化(去首尾空白 · 统一大小写等)→ 取指纹 │ 规范化的作用:让「同一个问题的不同写法」也能命中 │ ├─ 指纹 + 会话设定 命中缓存且未过期 → 直接返回缓存的回答 │ 不带会话设定的后果:不同设定下同一个问题应该有不同回答 │ ├─ 缓存有效期要短一些,并允许用户「重新生成」强制绕过缓存 │ 完全不让绕过的后果:用户想换个回答却拿不到 │ ├─ 单轮输入超长 → 提前拦截(见模块二) │ ├─ 单用户频率限制 → 超出直接拒绝 │ 不限的后果:脚本或误操作在短时间内产生大量调用 │ 我调试一个循环时不小心连续发了很多次,系统完全没有阻止 │ └─ 日预算检查 → 接近上限提示、超出暂停调用 没有上限的后果:出问题时会一直漏,直到你自己发现 调用与记账(每一次都要留痕) │ ├─ 调用后记录:输入用量 · 输出用量 · 折算费用 · 耗时 · 结果状态 │ 只有总账单的后果 │ 月底看到一个数字,无法定位哪些调用贵 │ 分不清是输入贵还是输出贵、有没有异常调用 │ 我发现账单比预期高不少,却查不出原因 │ ├─ 费用按接口的计费规则折算,并定期与账单核对 │ 核对是必要的:折算规则理解错了会一直错 │ └─ 按天与按会话聚合,能看出「哪些会话特别贵」 通常是长会话(每轮携带大量历史,见模块二) 重试策略:必须区分失败类型 │ ├─ 可重试:超时 · 网络错误 · 频率限制 · 上游临时不可用 │ 用指数退避,次数有限 │ ├─ 不可重试(确定性失败):参数错误 · 内容被拒 · 认证失败 │ 这类失败重试一百次也是失败,而每次尝试仍可能计费 │ 统一重试的后果:为注定失败的请求反复付钱 │ ├─ 频率限制类要按上游返回的建议等待时间退避 │ 立刻重试只会再次撞上限制 │ └─ 重试也要计入预算与频率限制,不能绕过 降级(失败之后要有出口) │ ├─ 单次失败 → 明确的失败态 + 重试入口(保留用户的问题,别让他重敲) │ ├─ 连续失败达到阈值 → 降级为「服务暂不可用」提示 │ 无限转圈的后果:用户不知道发生了什么,一直等 │ ├─ 超出日预算 → 明确提示「今日额度已用完」而不是笼统报错 │ 笼统报错会让用户以为是坏了,反复重试 │ └─ 降级期间历史记录仍然可查 因为查历史不需要调用上游,这部分能力应该保留 可观测 ├─ 按天的调用次数 · 费用 · 缓存命中率 · 失败率与失败原因分布 ├─ 缓存命中率是这个模块最直接的收益指标 └─ 失败原因分布能告出「确定性失败」占比,指导要不要改前置校验
    分步拆解
    1. 问题要先规范化再取指纹。去首尾空白、统一大小写这些处理让「同一个问题的不同写法」也能命中缓存。
    2. 缓存键要包含会话设定,不能只有问题指纹。因为不同设定下同一个问题应该有不同回答。
    3. 缓存有效期要短一些,并允许「重新生成」强制绕过。完全不让绕过的后果是用户想换个回答却拿不到。
    4. 要有单用户频率限制。不限的后果是脚本或误操作在短时间内产生大量调用——我调试一个循环时不小心连续发了很多次,而系统完全没有阻止。
    5. 要有日预算上限:接近时提示、超出则暂停调用。没有上限的后果是出问题时会一直漏,直到你自己发现——如果我当时睡着了,损失会大得多。
    6. 每次调用都要记录输入用量、输出用量、折算费用、耗时、结果状态。只有总账单的后果是月底看到一个数字却无法定位哪些调用贵。
    7. 折算费用要定期与账单核对。核对是必要的——计费规则理解错了会一直错,而且错得毫无察觉。
    8. 按天与按会话聚合费用。能看出「哪些会话特别贵」,通常是长会话(每轮携带大量历史)。
    9. 重试必须区分可重试与确定性失败。超时、网络错误、频率限制可以退避重试;参数错误、内容被拒、认证失败不能重试——这类失败重试一百次也是失败,而每次尝试仍可能计费。
    10. 频率限制类要按上游建议的等待时间退避。立刻重试只会再次撞上限制。
    11. 重试也要计入预算与频率限制,不能绕过。否则重试成了一个绕开所有保护的后门。
    12. 单次失败要给明确的失败态与重试入口,并保留用户的问题。别让他重新敲一遍。
    13. 连续失败达到阈值要降级为「服务暂不可用」提示。无限转圈的后果是用户不知道发生了什么,一直等。
    14. 超出日预算要明确提示「今日额度已用完」。笼统报错会让用户以为是坏了、反复重试。
    15. 降级期间历史记录仍然要可查。因为查历史不需要调用上游,这部分能力应该保留。
    16. 缓存命中率是这个模块最直接的收益指标,要统计。失败原因分布能告出「确定性失败」的占比,指导要不要加前置校验。
    关键决策与取舍

    对相同问题做缓存,代价是「同一个问题拿到同一个回答」。不缓存的话每次都是新生成的回答(有时确实会更好)。但我自己在调试和使用时会把同一个问题问很多遍,每一遍都是一次完整的费用——而其中很大一部分输入完全一样、回答也没必要不同我的处理是缓存有效期设得短,并且提供「重新生成」显式绕过——默认省钱、需要时可以要新的判据是「这次调用的输入和上次完全一样吗」:一样,那默认复用是合理的。

    重试区分失败类型,这是我认为最容易被忽略但最实在的一个改动。统一重试实现最简单(一个装饰器就搞定)。但确定性失败重试一百次也是失败,而每次尝试仍可能计费——我等于在为注定失败的请求反复付钱代价是要按错误类型分类,而分类要读接口文档、还要处理文档没写清的情况判据是「重试有没有可能成功」:没有可能,那重试就是纯损失。这一条在任何调用计费接口的场景里都成立,不只是大模型。

    加日预算上限,即使它会导致「今天用不了」。不加上限的话功能永远可用。但我真的遇到过一次误操作导致连续大量调用,而系统完全没有阻止——如果我当时睡着了,损失会大得多代价是极端情况下用户(也就是我)会被挡住判据是「失控的上限在哪」:没有上限就意味着损失没有上限,而一个可以被自己解除的限制,比一个没有限制的账单安全得多。

    逐次记账而不是只看总账单。只看总账单省掉了记录和折算的工作。但它让所有成本问题都不可定位——我发现账单比预期高不少,却完全查不出原因逐次记账之后才发现问题主要出在长会话上(每轮携带大量历史),这直接推动了模块二的裁剪判据是「出问题时你能不能定位」:不能,那就必须先把观测建起来。这个模块和模块二是这么连起来的:先能看见,才知道该优化什么。

    踩过的坑一:账单比预期高,但查不出原因。因为我只有一个总数、没有逐次记录。教训是:涉及费用的调用必须逐次留痕(用量、折算费用、结果状态),并且按维度聚合——不然你只能知道「贵」,不知道「为什么贵」。而记账建好之后我很快定位到是长会话导致的输入膨胀。

    踩过的坑二:统一重试把确定性失败也重试了。参数错误重试三次,三次都失败、三次都可能计费。教训是:重试的前提是「这次失败是偶然的」——如果失败是必然的,重试只是把一次损失变成三次。我现在写任何重试逻辑都会先列一遍「哪些错误重试有意义」。

    踩过的坑三:误操作导致连续大量调用,而系统没有任何阻止。我是在调试一个循环时不小心触发的,自己发现之后才停下来教训是:任何会产生真实成本的操作都要有一个「熔断」的上限,而且这个上限应该由系统强制,不能依赖操作者不出错——因为出错的往往就是操作者本人。

    没做的部分:没做语义相似问题的缓存(问法不同但意思一样也能命中)。它需要嵌入向量和相似度阈值,而嵌入本身也是一次收费调用——为了省一次调用先花一次调用,收益要算清楚才划算;而且阈值定不准会返回不相关的旧回答,那比多花一次钱更糟。取舍依据是「引入的成本与不确定性可能超过节省」。

    数字是怎么测的

    缓存命中率是这个模块最直接的收益指标:报「统计区间内的调用请求中命中缓存的比例」,并说明缓存键是「规范化问题指纹 + 会话设定」、有效期多长要诚实说明我自己调试时的重复提问占了其中很大一部分——这个坦白反而让数字更可信。

    费用对比要说清折算方式:「按接口计费规则手算的单次调用费用,与账单核对后偏差在可接受范围内」。然后报「相同问题重复提问 N 次的费用,改造前是 N 倍单次费用、改造后是 1 倍」——这个对比很直观也很容易核对。

    确定性失败的浪费要用失败原因分布来说明:报「失败原因分布中确定性失败占比 X%,改造前这部分每次都会被重试 N 遍」。这个数字直接量化了「统一重试」浪费掉多少。

    预算上限用断言型用例:把日预算设成一个很小的值,断言超出后调用被暂停、界面提示「今日额度已用完」而不是笼统报错、且历史记录仍可查。

    频率限制:用脚本在短时间内连续请求,断言超出频率后被拒绝、且被拒绝的请求不产生上游调用(可以看用量记录确认)。

    重试分类:分别构造超时与参数错误两种失败,断言前者被退避重试、后者不重试后者是踩坑的直接回归。

    降级:让上游持续失败,断言连续失败达到阈值后进入「暂不可用」提示而不是无限转圈;断言用户的问题被保留、不需要重新输入。

    记账完整性:断言每次调用(含失败的)都产生了一条记录,包含用量、折算费用、结果状态。

    不要报什么:不要报「成本降低 N%」而不说基线——重复提问的比例完全取决于使用方式。该报的是「缓存命中率及缓存键构成」「手算费用与账单核对的偏差」「相同问题重复 N 次的费用从 N 倍降到 1 倍」「确定性失败不再被重试」「超出预算时正确暂停并提示」这几件可核对的事。

    面试追问
    Q:调用大模型接口,除了把请求发出去还有什么工程问题? A:最大的工程问题是它花钱,而这个约束会改变你所有的设计判断。我以前做优化的思路都是「怎么让它更快、更稳」,这个项目让我第一次认真想「怎么让它别发」——我做的四件事全都是在减少调用次数,而不是优化单次调用。第一,相同问题命中缓存直接返回(缓存键是「规范化后的问题指纹 + 会话设定」,因为不同设定下同一问题应该有不同回答;有效期设得短,并提供「重新生成」显式绕过——默认省钱、需要时可以要新的)。第二,逐次记账。我一开始只有一个总账单,结果是月底看到一个数字,但完全无法定位哪些调用贵、是输入贵还是输出贵——我发现账单比预期高不少却查不出原因。记账建好之后我很快定位到问题主要出在长会话上(每轮携带大量历史),这直接推动了上下文裁剪那个改动教训是:出问题时能不能定位,决定了你要不要先建观测——先能看见,才知道该优化什么。第三,重试要区分可重试与确定性失败。我最初是统一重试,但参数错误、内容被拒这类失败重试一百次也是失败,而每次尝试仍可能计费——我等于在为注定失败的请求反复付钱重试的前提是「这次失败是偶然的」;如果失败是必然的,重试只是把一次损失变成三次。第四,日预算上限和单用户频率限制。我真的遇到过一次:调试一个循环时不小心让它连续发了很多次调用,而系统完全没有阻止,直到我自己发现——如果当时睡着了损失会大得多。教训是:任何会产生真实成本的操作都要有熔断上限,而且要由系统强制,不能依赖操作者不出错——因为出错的往往就是操作者本人。
    Q:失败了就重试,这有什么好区分的? A:区分的理由很实在:有一类失败重试是纯损失,因为它必然再次失败,而每次尝试仍然可能计费。我一开始加的是统一重试(失败就退避重试几次),后来在失败原因分布里看到有相当比例是「参数错误」和「内容被上游拒绝」——这两类重试一百次也是同样的结果所以我把失败分成两类可重试的是超时、网络错误、频率限制、上游临时不可用——这些是偶然的,退避重试有意义;不可重试的是参数错误、内容被拒、认证失败——这些是确定性的,重试只是把一次损失变成三次其中「频率限制」这一类还有个细节:要按上游返回的建议等待时间退避,立刻重试只会再次撞上限制。另外一个容易漏的点:重试本身也要计入预算和频率限制,不能绕过——否则重试就成了一个绕开所有保护的后门。失败处理还有「降级」这一层,我也踩过坑:单次失败要给明确的失败态和重试入口,并且保留用户的问题(别让他重新敲一遍);而连续失败达到阈值要降级为「服务暂不可用」提示,不能无限转圈——用户不知道发生了什么会一直等。超出日预算的时候要明确提示「今日额度已用完」而不是笼统报错,因为笼统报错会让他以为是坏了、反复重试。还有一条我觉得挺重要:降级期间历史记录仍然要可查——查历史不需要调用上游,这部分能力没有理由跟着一起挂掉。更一般的教训是:写任何重试逻辑之前先列一遍「哪些错误重试有意义」,而不是给所有异常套一个统一的重试。

模块四:提示词的版本化与效果回归

  1. 提示词的版本化与效果回归(从硬编码改为可版本化的配置 + 固定问题集做前后对比 + 记录每条回答用的是哪个版本 + 判断标准要先写下来)★★★
    简历这样写 提示词版本化与回归(Spring Boot + MySQL + 配置化管理):提示词原为硬编码在代码里、改动直接覆盖,导致改坏之后回不去、也无法判断某次改动是变好还是变坏;改为存库并带版本号管理(新版本追加而不是覆盖,可随时切回旧版本),并在每条回答上记录它当时使用的提示词版本,使任何一条历史回答都能追溯到具体版本;建立固定问题集做回归对比——改动前后用同一批问题各跑一遍、把结果并排放在一起人工判断,替代了原先「凭印象觉得变好了」的做法;判断标准在改动之前先写下来(这次改动想解决的是哪一类问题),避免看到结果后为它找理由;同时记录每个版本的输入长度与单次成本,因为提示词变长会直接抬高每一次调用的费用。改造后提示词的每次调整都可回滚、可对比。
    展开完整拆解
    为什么要这么设计

    提示词是这个项目里我改动最频繁的东西——几乎每天都在调而我最初把它当成普通的代码字符串写死在类里,四个问题很快就来了。

    一是改坏了回不去。我调提示词的方式是直接改那段字符串。有一次我为了让回答更简洁改了一版,用了两天发现它在处理代码问题时变差了——但我已经记不清原来那段是怎么写的了提示词不像代码逻辑,它是一整段自然语言,改动之后原文就真的没了。

    二是说不清一次改动是变好还是变坏。我每次改完就随便问几个问题,觉得「好像好一点」就留下但我问的问题每次都不一样、而且往往是我刚好在想的那个问题——这等于没有对比更糟的是我会不自觉地挑那些能证明「改对了」的例子。

    三是历史回答无法追溯它当时用的是哪一版。我翻自己的历史对话时看到一条回答很奇怪,但完全不知道它是哪个版本的提示词产生的——是当时的提示词有问题,还是模型偶发,我判断不了。

    四是提示词变长直接抬高了成本,而我没有察觉。我为了让回答更规范,往提示词里加了不少约束和示例。这些内容每一次调用都要作为输入发出去——而输入是计费的我是在对账用量时才发现单次调用的输入长度比之前涨了不少。

    所以四个改动:提示词存库并带版本号、新版本追加而不覆盖建立固定问题集做改动前后的并排对比每条回答记录它使用的提示词版本记录每个版本的输入长度与单次成本

    这个模块最想说的一句话是:提示词是配置而不是代码,而配置需要版本、需要能回滚、需要有对比的办法。我一开始把它当成代码里的一个字符串,所以既没有版本也没有对比——每次改动都是不可逆的,而「是不是变好了」全靠我的主观印象而印象是最不可靠的:我会挑那些证明自己改对了的例子。

    整体链路
    提示词的存储与版本 │ ├─ 存库,一条记录 = 一个版本 │ 字段:用途标识 · 版本号 · 内容 · 创建时间 · 是否启用 · 备注 │ 备注里写「这一版想解决什么问题」—— 后面回看时很有用 │ ├─ 新版本追加,绝不覆盖旧版本 │ 直接改字符串的后果(我踩过) │ 改了一版让回答更简洁,两天后发现处理代码问题时变差了 │ 而我已经记不清原来那段是怎么写的了 │ 提示词是一整段自然语言,改动之后原文就真的没了 │ ├─ 切换启用版本即生效,可随时切回旧版本 │ └─ 每条回答记录它当时使用的版本号 不记录的后果 翻历史看到一条奇怪的回答,不知道是哪版提示词产生的 是提示词有问题还是模型偶发,判断不了 回归对比(替代「凭印象觉得变好了」) │ ├─ 维护一个固定的问题集 │ 要覆盖不同类型:概念解释 · 代码问题 · 长文本 · 边界与刁难 │ 问题集固定不变,这是能对比的前提 │ ├─ 改动前后用同一批问题各跑一遍 │ 随便问几个的后果 │ 每次问的问题都不一样、而且是我刚好在想的那个 │ 这等于没有对比 │ 更糟的是我会不自觉地挑能证明「改对了」的例子 │ ├─ 结果并排放在一起人工判断(我没有自动评分的能力) │ └─ 判断标准必须在改动之前先写下来 「这次改动想解决的是哪一类问题」 不先写的后果:看到结果后为它找理由,怎么看都觉得变好了 成本也是版本的一部分 │ ├─ 记录每个版本的输入长度(用与计费一致的方式计算) │ ├─ 提示词变长会直接抬高每一次调用的费用 │ 为了让回答更规范,我往提示词里加了不少约束和示例 │ 这些内容每一次调用都要作为输入发出去,而输入是计费的 │ 我是在对账用量时才发现单次输入长度涨了不少 │ └─ 所以「效果变好但成本翻倍」这种改动要被明确看到,而不是悄悄发生 多个用途的提示词要分开管理 ├─ 不同用途(普通问答 · 代码解释 · 总结)各自独立版本 ├─ 混在一份提示词里的后果:改一个用途影响其他用途 └─ 公共部分可以抽出来,但要清楚它被哪些用途引用 改动的操作纪律 ├─ 一次只改一处,改完立刻跑回归 │ 一次改多处的后果:结果变了但不知道是哪一处的作用 ├─ 每一版都写备注(想解决什么、观察到什么) └─ 发现新问题时先补进问题集,再去改提示词 这样这个问题以后每次回归都会被检查到
    分步拆解
    1. 提示词必须存库并带版本号,不能硬编码。它是配置而不是代码——而配置需要版本、需要能回滚。
    2. 新版本追加,绝不覆盖旧版本。直接改字符串的后果是改坏了回不去——提示词是一整段自然语言,改动之后原文就真的没了。
    3. 每一版都要写备注:这一版想解决什么问题。后面回看时非常有用,而当时不写就再也想不起来了。
    4. 切换启用版本即生效,且可随时切回。回滚要能立刻做到,不能依赖发版。
    5. 每条回答要记录它当时使用的提示词版本号。不记录的后果是翻历史看到一条奇怪的回答,判断不了是提示词的问题还是模型偶发。
    6. 维护一个固定的问题集,覆盖不同类型。概念解释、代码问题、长文本、边界与刁难——固定不变是能对比的前提。
    7. 改动前后用同一批问题各跑一遍,结果并排看。随便问几个的后果是每次问的都不一样、而且是我刚好在想的那个,等于没有对比。
    8. 判断标准必须在改动之前先写下来。不先写的后果是看到结果后为它找理由,怎么看都觉得变好了。
    9. 要承认自己没有自动评分的能力,人工并排判断就是当前的方式。诚实说明这一点,比编一个「准确率提升」要好。
    10. 一次只改一处,改完立刻跑回归。一次改多处的后果是结果变了但不知道是哪一处的作用。
    11. 发现新问题时先补进问题集,再去改提示词。这样这个问题以后每次回归都会被检查到——否则修好了又会被下一次改动破坏。
    12. 记录每个版本的输入长度与单次成本。提示词变长会直接抬高每一次调用的费用——我是在对账用量时才发现输入长度涨了不少。
    13. 「效果变好但成本翻倍」要被明确看到。把成本作为版本的一部分记录,而不是让它悄悄发生。
    14. 不同用途的提示词要分开管理。混在一份里的后果是改一个用途影响其他用途。
    15. 公共部分可以抽出来,但要清楚它被哪些用途引用。否则改公共部分时不知道会影响到哪些地方。
    关键决策与取舍

    提示词存库而不是留在代码里,代价是多一层配置管理。留在代码里有它的好处:跟着版本控制走、改动有记录、部署简单。但它有两个问题:一是改动即覆盖,我没法在运行时切回旧版本(要改代码重新发布);二是我调提示词的频率远高于发版的频率——几乎每天都在调判据是「这个东西的改动频率和代码是不是同一个量级」:不是,那它就该按配置管理而不是按代码管理而配置的三个基本要求是版本、回滚、以及能看出「现在用的是哪一版」。

    新版本追加而不覆盖,接受存储上的冗余。覆盖最省空间、库里也干净。但提示词和代码不一样——代码改坏了可以从版本历史里找回来,而一段自然语言被覆盖之后就真的没了(我踩过这个坑,改了一版之后再也拼不出原来那段)。代价是库里会积累很多历史版本判据是「这份内容重建的成本有多高」:一段调了很久的提示词,重建成本极高,那就绝对不能覆盖。

    用固定问题集做人工并排对比,而不是做自动评分。自动评分(比如让模型给回答打分)听起来更系统。但我没有能力验证「评分本身是否可靠」——用一个我无法评估的评分器去评估另一个东西,等于把不确定性叠加了一层而且评分本身也是一次收费调用所以我选了最朴素的方式:固定问题集、并排放、人工看它不高级,但每一步我都能解释判据是「我能不能验证这个方法本身是对的」:能验证的才用。

    判断标准在改动之前先写下来,这是这个模块里我认为最有价值的一条纪律。它不涉及任何技术,但它防的是一个我真实存在的倾向:看到结果之后为它找理由我发现自己会不自觉地挑那些能证明「改对了」的例子,而忽略变差的那些先写下「这次改动想解决的是哪一类问题」,就把判断的靶子固定住了。

    踩过的坑一:改了一版提示词,两天后发现在代码问题上变差了,而原来那段已经找不回来了。我试着凭记忆重写,但怎么写都感觉不是原来那个效果教训是:任何「一整段自然语言」性质的配置都必须版本化——它不像代码那样有明确的结构可以还原,改掉就是真的没了。

    踩过的坑二:我以为自己在验证效果,实际上只是在找证据。每次改完随便问几个问题、觉得好像好一点就留下。而我问的问题每次都不一样、还往往是我刚好在想的那个教训是:没有固定的对照就没有对比,而人在没有约束时会自然地挑选有利的证据——所以问题集要固定、判断标准要提前写。

    踩过的坑三:往提示词里加约束和示例,把每次调用的成本抬上去了。我是对账用量时才发现的。教训是:提示词里的每一个字都会在每一次调用里被重复发送——它不是一次性成本,而是乘以调用次数的持续成本所以「效果好一点但提示词长了很多」这种改动要算清账再决定。

    没做的部分:没做提示词的自动化评测(用一批带标准答案的题目自动打分)。它需要一批可靠的标准答案,而这类题目的「标准答案」本身就很难定——尤其是开放性问题而对于有确定答案的题目(比如代码能不能跑),自动判定是可行的,这是我认为下一步值得做的方向取舍依据是「先做我能保证正确性的那部分」。

    数字是怎么测的

    这个模块不适合报效果类数字,该报「机制是否具备」这些可核对的事实:提示词有版本号、新版本追加不覆盖、可切回任意旧版本、每条回答记录了它使用的版本号这几条面试官可以当场追问实现,而且都能直接演示。

    回滚能力用断言型用例:切换到新版本、再切回旧版本,断言两次的输出与对应版本一致、且历史回答上记录的版本号没有被改动。

    可追溯性:随机取一条历史回答,断言能查到它当时使用的提示词版本的完整内容这一条是「说不清是提示词问题还是模型偶发」这个坑的直接回归。

    回归对比要报流程和问题集规模,而不是报分数:「问题集包含 N 个固定问题,覆盖概念解释、代码问题、长文本、边界刁难四类;每次改动前后各跑一遍、并排人工判断」。并诚实说明「判断是人工的,我没有自动评分能力」——这个诚实反而让整套流程更可信。

    判断标准前置这一条可以报纪律:「每一版的备注里都写了『这一版想解决什么问题』,且备注的写入时间早于结果的产生时间」。时间顺序是可核对的,这让「先写标准」不只是一句口号。

    成本变化必须报,这是最容易被忽略的一项:「各版本提示词的输入长度与单次调用成本」,用与计费一致的方式计算并与账单核对报法是「某一版为了规范输出增加了约束与示例,单次输入长度上升了 X,对应单次成本上升了 Y」——把这个代价明确写出来,比只说「效果变好了」完整得多。

    问题集的增长也值得报:「发现新问题时先补进问题集再改提示词,问题集从 N 个增长到 M 个」。它说明这套流程在真的运转,而不是建完就放着。

    不要报什么:不要报「回答质量提升 N%」——我没有可靠的评分方式,这个数字编不出也验证不了。该报的是「版本可追加与回滚」「历史回答可追溯到具体版本」「固定问题集的规模与类型覆盖」「各版本的输入长度与单次成本对比」这几件可核对的事。

    面试追问
    Q:提示词就是一段字符串,写在代码里不是最简单吗? A:最简单,我一开始就是这么做的,但两个真实的问题让我改了。第一个是改坏了回不去。我有一次为了让回答更简洁改了一版,用了两天才发现它在处理代码问题时变差了——而我已经记不清原来那段是怎么写的了。我试着凭记忆重写,但怎么写都感觉不是原来那个效果教训是:提示词和代码不一样——代码改坏了可以从版本历史里找回来,而一整段自然语言被覆盖之后就真的没了,它没有明确的结构可以还原。第二个是改动频率。我调提示词几乎每天都在做,而它写在代码里意味着每次调整都要走一遍发布流程,也没法在运行时切回旧版本判据是「这个东西的改动频率和代码是不是同一个量级」:不是,那它就该按配置管理而不是按代码管理——而配置的三个基本要求是版本、回滚、以及能看出「现在用的是哪一版」。所以现在是存库、一条记录一个版本、新版本追加绝不覆盖、切换启用版本即生效,每一版都写备注说明「这一版想解决什么问题」。还有一个我后来补上、但很关键的东西:每条回答都记录它当时使用的提示词版本号。因为我遇到过这样的情况——翻历史对话看到一条回答很奇怪,但完全不知道它是哪个版本的提示词产生的,是提示词有问题还是模型偶发,我根本判断不了记上版本号之后,任何一条历史回答都能追溯到当时的完整提示词。
    Q:你怎么知道改完提示词是变好了还是变坏了? A:我一开始的做法是错的,而且错得很典型:改完随便问几个问题,觉得「好像好一点」就留下。问题是我问的问题每次都不一样、而且往往是我刚好在想的那个——这等于没有对比;更糟的是我会不自觉地挑那些能证明「改对了」的例子,而忽略变差的那些。所以现在的做法是维护一个固定的问题集(覆盖概念解释、代码问题、长文本、边界与刁难几类),改动前后用同一批问题各跑一遍、把结果并排放在一起人工判断——问题集固定不变是能对比的前提还有一条纪律我认为比技术更重要:判断标准必须在改动之前先写下来,也就是「这次改动想解决的是哪一类问题」。不先写的后果是看到结果之后为它找理由,怎么看都觉得变好了——这防的是我自己真实存在的倾向。另外两条操作纪律一次只改一处、改完立刻跑回归(一次改多处的话,结果变了但不知道是哪一处的作用);发现新问题时先补进问题集、再去改提示词——这样这个问题以后每次回归都会被检查到,否则修好了又会被下一次改动破坏我要诚实说明一点:判断是人工的,我没有自动评分的能力。我考虑过让模型给回答打分,但我无法验证「评分本身是否可靠」——用一个我无法评估的评分器去评估另一个东西,等于把不确定性叠加了一层,而且评分本身也是一次收费调用判据是「我能不能验证这个方法本身是对的」,能验证的才用。最后还有一项容易被忽略的:提示词变长会直接抬高每一次调用的成本。我为了规范输出往里加了约束和示例,是对账用量时才发现单次输入长度涨了不少——提示词里的每一个字都会在每一次调用里被重复发送,它不是一次性成本,而是乘以调用次数的持续成本。所以我把每个版本的输入长度和单次成本也记了下来。

模块五:模型输出的不可信处理

  1. 模型输出的不可信处理(把输出当外部内容而非自己生成的 + 渲染前转义与链接白名单 + 结构化输出的解析失败重试 + 用户输入被当成指令的问题)★★★
    简历这样写 输出安全与结构化解析(Spring Boot + Vue + 受限渲染):模型回答需要按富文本渲染(代码块、列表、链接),初版直接把输出插入页面,实测中构造一个提问就能让模型输出被当作页面结构解析——这等于给了一条注入路径;改为把模型输出当作外部不可信内容:走受限渲染(只允许有限的格式)、先转义再渲染输出中的链接做白名单与显式标注(避免把用户导向任意外部地址);需要结构化结果的场景(如从回答中提取字段)改为约束输出格式 + 解析失败按有限次重试 + 仍失败则降级为纯文本展示,替代了原先解析异常直接报错的做法;同时区分开用户输入系统设定在提示中的位置与标注,降低用户输入被当作指令覆盖设定的可能。相关问题均通过构造特定提问复现。
    展开完整拆解
    为什么要这么设计

    模型输出是我在这个项目里最晚才意识到「它是外部内容」的一样东西我一直下意识地把它当成「我自己的系统产生的数据」,所以直接就渲染了。四个问题。

    一是直接渲染输出,等于开了一条注入路径。我为了支持代码块和列表,把模型的输出按富文本渲染。而模型的输出内容是由提问决定的——我构造一个提问,让它输出一段带页面标记的内容,这段内容就被当作页面结构解析了我是自己乱试的时候撞出来的:我让它「原样输出下面这段文本」,然后给了一段标记。这件事让我意识到:模型输出的内容我完全控制不了,它本质上和用户输入一样不可信。

    二是输出里的链接可以指向任何地方。模型会在回答里给参考链接。而这些链接是它生成的、不是我校验过的——用户点了就被带到一个我完全不了解的地址更麻烦的是链接文字和实际地址可以不一致(显示一个正规的名字、指向另一个地方)。

    三是需要结构化结果时,解析失败就直接报错了。有个功能是从回答里提取几个字段。我要求模型按固定格式输出,然后解析。但模型并不总是严格按格式来——多一句解释、少一个符号,解析就抛异常而用户看到的是一个报错,他完全不知道发生了什么。

    四是用户输入可以覆盖我的设定。我把系统设定和用户问题拼在一起发过去。用户在提问里写「忽略之前的所有要求」,有时候确实能让模型不再遵守我的设定这一点我没有能力完全解决,但至少要把两者的位置和标注分开、并且不依赖设定来做任何安全相关的判断。

    所以四个改动:把模型输出当外部不可信内容、走受限渲染并先转义输出中的链接做白名单与显式标注结构化输出改为约束格式加解析失败重试、仍失败降级为纯文本区分用户输入与系统设定的位置与标注

    这个模块最想说的一句话是:模型的输出是外部内容,不是我的系统生成的数据——而我一直下意识地把它当成后者,所以直接渲染了。它的内容由提问决定,而提问是用户写的,所以「模型输出」本质上是「用户输入经过一次变换」——既然用户输入不可信,那它的变换结果同样不可信。想清楚这个传递关系之后,转义、白名单、格式校验这几件事就都是自然的了。

    整体链路
    先建立一个认知:模型输出属于外部内容 │ ├─ 输出的内容由提问决定,而提问是用户写的 │ 所以「模型输出」本质上是「用户输入经过一次变换」 │ 既然用户输入不可信,它的变换结果同样不可信 │ └─ 我一直下意识把它当成「我自己系统产生的数据」,所以直接渲染了 渲染(这里是注入风险点) │ ├─ 受限渲染:只允许有限的格式(段落 · 列表 · 代码块 · 加粗) │ 不做限制、按完整富文本渲染的后果(我踩过) │ 我构造一个提问,让它「原样输出下面这段文本」并给一段标记 │ 这段内容就被当作页面结构解析了 —— 等于一条注入路径 │ ├─ 顺序必须是:先转义文本 → 再插入我自己生成的格式标记 │ 顺序反了的后果:我自己插的标记也被转义掉,格式失效 │ 不转义的后果:模型输出的内容能破坏页面结构 │ ├─ 代码块内的内容一律按纯文本处理,不做任何解析 │ └─ 流式渲染时未闭合的标记要临时补全(见流式输出那一块) 输出中的链接 │ ├─ 链接由模型生成,不是我校验过的 │ 用户点了就被带到一个我完全不了解的地址 │ 而链接文字和实际地址可以不一致(显示正规名字、指向别处) │ ├─ 处理方式 │ 不在白名单内的域名 → 不渲染成可点击链接,只显示纯文本地址 │ 渲染成链接时,把真实地址显式标注出来 │ 在新标签打开并加上不携带来源信息的属性 │ └─ 界面上明确提示「链接由模型生成,请自行判断」 这不是免责,而是让用户知道它没有经过校验 结构化输出(需要从回答里提取字段时) │ ├─ 在提示里明确约束输出格式,并给出示例 │ ├─ 解析前先做宽容处理(去掉前后多余的说明文字等) │ 模型并不总是严格按格式来 —— 多一句解释、少一个符号都会失败 │ ├─ 解析失败 → 有限次重试(可以在重试时强调格式要求) │ ├─ 仍失败 → 降级为纯文本展示,而不是抛错 │ 直接报错的后果:用户看到一个错误,完全不知道发生了什么 │ 而回答本身可能是有用的,只是格式不对 │ └─ 重试要计入成本与频率限制(每次重试都是一次收费调用) 用户输入与系统设定的关系 │ ├─ 两者在提示里的位置与标注要分开 │ ├─ 用户输入不做任何「指令化」的处理,只作为内容 │ ├─ 但要承认:这防不住所有情况 │ 用户写「忽略之前的所有要求」,有时确实能让模型不再遵守设定 │ 这一点我没有能力完全解决 │ └─ 所以关键原则是:不依赖模型的设定来做任何安全相关的判断 权限校验 · 数据过滤 · 频率限制,全部在服务端代码里做 而不是「在提示词里要求模型不要做某件事」 输入侧的检测 ├─ 明显超长的输入提前拦截(见上下文窗口那一块) ├─ 明显违规的内容在发出前就拦掉,省一次调用 └─ 但不要过度拦截 —— 误拦正常问题的代价更大
    分步拆解
    1. 先把「模型输出属于外部内容」这个认知建立起来。它的内容由提问决定、而提问是用户写的——所以它本质上是「用户输入经过一次变换」,同样不可信。
    2. 渲染必须受限,只允许有限的格式。按完整富文本渲染的后果是我构造一个提问让它输出一段标记,这段内容就被当作页面结构解析了。
    3. 顺序必须是「先转义文本、再插入我自己生成的格式标记」。顺序反了的后果是我自己插的标记也被转义掉、格式失效。
    4. 代码块内的内容一律按纯文本处理。代码块里最可能出现各种特殊字符,绝不能对它做任何解析。
    5. 输出里的链接不能直接渲染成可点击的。链接由模型生成、不是我校验过的——而链接文字和实际地址可以不一致(显示正规名字、指向别处)。
    6. 不在白名单内的域名只显示纯文本地址,不渲染成链接。让用户自己决定要不要复制过去。
    7. 渲染成链接时要把真实地址显式标注出来。并在新标签打开、加上不携带来源信息的属性。
    8. 界面上要提示「链接由模型生成,请自行判断」。这不是免责,而是让用户知道它没有经过校验。
    9. 需要结构化结果时,在提示里明确约束格式并给示例。但要知道模型并不总是严格按格式来。
    10. 解析前做宽容处理。去掉前后多余的说明文字等——多一句解释就解析失败是很常见的。
    11. 解析失败要有限次重试,重试时可以强调格式要求。重试要计入成本与频率限制——每次重试都是一次收费调用。
    12. 仍失败时降级为纯文本展示,而不是抛错。直接报错的后果是用户看到一个错误、完全不知道发生了什么,而回答本身可能是有用的,只是格式不对。
    13. 用户输入与系统设定在提示里的位置与标注要分开。用户输入只作为内容,不做任何指令化的处理。
    14. 要承认「防不住所有情况」。用户写「忽略之前的所有要求」有时确实能让模型不再遵守设定——这一点我没有能力完全解决。
    15. 所以绝不依赖模型的设定来做任何安全相关的判断。权限校验、数据过滤、频率限制全部在服务端代码里做——而不是「在提示词里要求模型不要做某件事」。
    16. 输入侧的检测不要过度。明显超长与明显违规的拦掉即可,误拦正常问题的代价更大。
    关键决策与取舍

    把模型输出当作外部不可信内容处理,这是这个模块的全部出发点。直接渲染最省事,而且直觉上会觉得「这是我的系统调用接口拿回来的数据,应该是可信的」但输出的内容由提问决定,而提问是用户写的——所以它本质上是「用户输入经过一次变换」既然用户输入不可信,它的变换结果同样不可信判据是「这段内容的实际决定者是谁」:是用户,那它就是外部输入想清楚这个传递关系之后,转义、白名单、格式校验这几件事就都是自然的了——而在想清楚之前,我完全没觉得需要做这些。

    受限渲染而不是完整富文本渲染。完整渲染能让回答的排版更好看(表格、嵌套结构)。但它意味着模型输出的任何标记都会被解析——而我控制不了模型输出什么代价是复杂格式显示不出来判据是「这个内容的产生者是否可信」:不可信,那渲染能力就必须受限这一条和我在通知模板那类场景里的判断是同一个原则:不可信输入不能获得完整的渲染能力。

    链接做白名单而不是全部禁止、也不是全部放开。全部禁止最安全,但模型给的参考链接确实经常是有用的,一律禁掉会削弱回答的价值全部放开则可能把用户导向任意地址,而且链接文字和实际地址可以不一致白名单加显式标注是一个中间选择可信域名正常渲染、其余只显示纯文本地址让用户自己判断代价是白名单需要维护、而且会漏掉一些其实无害的域名判据是「用户能不能自己判断这个链接安全吗」:不能(因为显示文字可以骗人),那就必须由系统先做一层过滤。

    结构化解析失败时降级为纯文本,而不是抛错。抛错最直接(格式不对就是失败)。但回答本身可能是有用的,只是格式不符合我的期望——把它整个丢掉是浪费而用户看到一个报错完全不知道发生了什么代价是要多一条降级路径、界面上要能处理两种展示形态判据是「失败的时候有没有一个仍然有价值的退路」:有(纯文本展示),那就该降级而不是报错。

    承认「用户输入覆盖系统设定」这件事我防不住,并据此确定了一条原则。我可以在提示词里反复强调「不要理会用户的其他要求」。但我实测发现这类约束并不可靠——而更重要的是,我不应该把安全性建立在一个不可靠的约束上所以我的原则是:绝不依赖模型的设定来做任何安全相关的判断——权限校验、数据过滤、频率限制全部在服务端代码里做我认为这一条比「怎么写更强的提示词」重要得多,因为它把安全性从「概率上有效」变成了「结构上有效」。

    踩过的坑一:直接渲染模型输出,被自己构造的提问打出了注入。我让它「原样输出下面这段文本」然后给了一段标记,那段内容被当作页面结构解析了教训是:我一直下意识地把模型输出当成「我自己系统产生的数据」,而它其实是外部内容——这个认知错误是根源,具体的转义、白名单都只是它的推论。

    踩过的坑二:结构化解析动不动就失败,而我直接抛错了。模型多说一句解释、少一个符号,解析就挂。教训是:对一个「不保证严格遵守格式」的来源做解析,必须假设它会失败——而失败的处理不该是报错,应该是降级到一个仍然有用的形态。

    踩过的坑三:我曾经想靠提示词来约束安全行为。比如在设定里写「不要输出任何链接」。后来实测发现这类约束在特定提问下会被绕过教训是:不要把安全性建立在一个概率性有效的机制上——能在代码里做的判断,就不要交给提示词。

    没做的部分:没做输出内容的语义级审核(判断回答是否包含不当内容)。它需要接一个内容检测能力,而检测的准确性我无法评估、误判会拦掉正常回答;而且流式输出下「边生成边审核」的时机也很难处理(一句话说到一半时无法判断)。取舍依据是「我无法评估其准确性的机制,接进来反而增加不确定性」——我做的是把结构层面的风险(注入、链接、格式)处理干净,而语义层面的判断留给更成熟的能力。

    数字是怎么测的

    注入是断言型用例,也是这个模块最重要的一条:构造一个让模型输出页面标记的提问(比如要求它原样输出一段标记文本),断言这段内容以纯文本形式显示、没有被解析成页面结构、没有产生任何可执行内容这条要保留成回归用例,因为渲染逻辑一改就可能被破坏。

    代码块内容:让模型输出一段包含各种特殊字符的代码,断言它们全部按原样显示、页面结构完整。

    链接白名单:让模型输出一个白名单外的地址,断言它只显示为纯文本、不可点击;输出白名单内的地址,断言渲染为链接、真实地址被显式标注、在新标签打开。

    链接文字与地址不一致:构造一个「显示文字是常见网站名、实际地址是别处」的输出,断言界面上展示的是真实地址而不是只显示文字。

    结构化解析的失败率值得报:「用固定的一批提问测试,约束格式后解析成功率为 X;失败的那部分经过一次重试后成功率为 Y;仍失败的降级为纯文本」。三个数字一起报,才说明这条链路是完整的——而不是只报「成功率很高」。

    解析的宽容处理:构造「格式正确但前后多了说明文字」的输出,断言仍能被正确解析(这是最常见的一种偏差)。

    重试的成本约束:断言解析失败的重试计入了调用次数统计与频率限制,不会绕过预算控制。

    设定覆盖:我会诚实报「我构造了若干试图覆盖设定的提问,其中有部分确实让模型不再遵守设定」——并说明正是因为这个结果,我把所有安全相关的判断都放在了服务端代码里,而不是依赖提示词报一个「没能完全防住」的结果,比声称防住了更可信。

    不要报什么:不要说「输出是安全的」——语义层面的风险我没有处理。该报的是「构造注入型提问后输出以纯文本呈现」「白名单外链接不可点击、真实地址被标注」「结构化解析的成功率、重试后成功率与降级路径三个数」「试图覆盖设定的测试结果及我据此采取的原则」这几件可核对的事。

    面试追问
    Q:模型的输出是你自己调接口拿回来的,为什么还要当成不可信内容? A:因为输出的内容是由提问决定的,而提问是用户写的——所以「模型输出」本质上是「用户输入经过一次变换」。既然用户输入不可信,它的变换结果同样不可信。这个认知我是被自己构造的一个提问打醒的:我为了支持代码块和列表,把模型输出按富文本渲染;然后我自己乱试的时候让它「原样输出下面这段文本」并给了一段页面标记——那段内容就被当作页面结构解析了,等于开了一条注入路径教训是:我一直下意识地把模型输出当成「我自己系统产生的数据」,而它其实是外部内容——这个认知错误是根源,具体的转义、白名单都只是它的推论。想清楚之后处理就明确了:受限渲染(只允许段落、列表、代码块、加粗这些有限格式)顺序必须是「先转义文本、再插入我自己生成的格式标记」(顺序反了我自己插的标记也会被转义掉)、代码块内的内容一律按纯文本处理不做任何解析另一处我一开始完全没想到的风险是输出里的链接链接是模型生成的、不是我校验过的,用户点了就被带到一个我完全不了解的地址;而且链接文字和实际地址可以不一致——显示一个正规的名字、指向别处我的处理是白名单加显式标注白名单内的域名正常渲染成链接并把真实地址标注出来,白名单外的只显示纯文本地址、不可点击,界面上还提示「链接由模型生成,请自行判断」。判据是「用户能不能自己判断这个链接安全」:不能(显示文字可以骗人),那就必须由系统先做一层过滤。
    Q:用户在提问里写「忽略之前的所有要求」,你怎么防? A:我防不住,而这个「防不住」恰恰决定了我的一条原则。先说我做了什么:把用户输入与系统设定在提示里的位置和标注分开,用户输入只作为内容、不做任何指令化的处理但我实测过——我构造了若干试图覆盖设定的提问,其中有部分确实让模型不再遵守我的设定。我也试过在设定里反复强调「不要理会用户的其他要求」,但这类约束并不可靠所以我得出的原则是:绝不依赖模型的设定来做任何安全相关的判断。权限校验、数据过滤、频率限制、成本上限,全部在服务端代码里做——而不是「在提示词里要求模型不要做某件事」我认为这一条比「怎么写更强的提示词」重要得多,因为它把安全性从「概率上有效」变成了「结构上有效」不管模型被怎么绕过,它都无法越过服务端的校验,因为那不是它能触及的地方。我也踩过相反方向的坑:我曾经在设定里写「不要输出任何链接」,然后实测发现特定提问下会被绕过——教训是不要把安全性建立在一个概率性有效的机制上,能在代码里做的判断就不要交给提示词。顺带说一个相关但独立的问题:结构化输出的解析。有个功能要从回答里提取字段,我约束了输出格式,但模型并不总是严格按格式来——多一句解释、少一个符号,解析就抛异常,而用户看到的是一个报错现在的做法是:解析前做宽容处理(去掉前后多余的说明文字)、失败则有限次重试、仍失败降级为纯文本展示而不是报错——因为回答本身可能是有用的,只是格式不对;而重试也要计入成本和频率限制,每次重试都是一次收费调用。

模块六:会话与历史的存储检索

  1. 会话与历史的存储检索(会话表消除按消息表分组 + 标题自动生成而非「新会话」+ 长回答的存储与列表摘要分离 + 删除的语义与导出)★★
    简历这样写 会话与历史管理(Spring Boot + MySQL):会话列表初版由消息表分组取每个会话最后一条,消息量增长后成为慢查询,改为独立维护会话表(标题、最后一条摘要、更新时间、消息数),列表查询按索引直接取;会话标题从统一显示「新会话」改为由首轮提问自动生成并允许手动改名(原先列表里全是同名条目,用户找不到自己要的那一段对话);模型回答可能很长,列表摘要改为单独存一份截断后的摘要而不是每次从正文截取(正文可能很大,列表页拉全文既慢又浪费);按内容搜索历史对话用全文索引实现并说清适用边界;删除区分删除单条消息删除整个会话两种语义,并提供导出为文本以免用户因为担心丢失而不敢删除。全部结论在灌入数千轮对话的条件下实测。
    展开完整拆解
    为什么要这么设计

    会话历史是我用得最久之后才发现问题的一块——因为它的问题都需要「积累了足够多的对话」才会出现。我自己用了一段时间、又灌了几千轮对话之后,四个问题一起来了。

    一是会话列表越来越慢。我没有独立的会话概念,列表是「从消息表里找出所有会话、按会话分组、每组取最后一条」算出来的对话积累起来之后这个查询明显慢下来——而它是打开应用第一眼就要看到的东西这个问题和我在聊天项目里遇到的完全同源。

    二是列表里全是「新会话」,我自己都找不到想要的那段对话。我给每个会话的默认标题就是「新会话」。积累了几十个之后,列表里是一整屏同名条目——我只能一个个点开找而我要找的往往是「上周问过的那个问题」,靠时间也不好定位。

    三是列表页很慢,因为它把长回答的正文也拉出来了。我的列表是取每个会话的最后一条消息作为摘要。而模型的回答可能很长(几千字的代码解释很常见)——列表拉二十个会话就意味着拉二十段长文本,然后在前端截断传输的绝大部分内容是被丢掉的。

    四是我不敢删除,因为删了就没了。历史对话里有一些我觉得以后可能有用的回答。而删除是不可逆的、也没有导出——于是我一条都不敢删,历史越积越多,列表越来越难找问题的根源不是「删除功能不好用」,而是「我没有一个安全的退路」。

    所以四个改动:建独立会话表并维护标题、摘要、更新时间、消息数标题由首轮提问自动生成并允许改名列表摘要单独存一份截断后的内容提供导出,并把删除区分成删单条与删整个会话,另外补了按内容搜索历史对话

    这个模块最想说的一句话是:一个「历史记录」功能好不好用,取决于用户能不能找到他要的那一条——而不是取决于它存得有多全。我最初只关心「把对话都存下来」,所以标题全是「新会话」、没有搜索、也不敢删——结果是存得越全越没用标题自动生成、按内容搜索、以及「可以放心删」这三件事,合起来才让历史真正可用。

    整体链路
    会话表(不要从消息表算列表) │ ├─ 一条会话记录:标题 · 最后一条摘要 · 更新时间 · 消息数 · 创建时间 │ ├─ 每次发消息时更新会话(摘要 · 更新时间 · 消息数) │ 和消息落库放在同一个事务里,避免列表与消息不一致 │ ├─ 列表查询按(用户, 更新时间)索引取,游标分页 │ 从消息表分组取最后一条的后果 │ 对话积累后明显变慢 │ 而它是打开应用第一眼就要看到的东西 │ └─ 会话表的行数增长远慢于消息表 —— 这是两者的本质区别 标题(决定用户能不能找到) │ ├─ 首轮提问结束后自动生成一个简短标题 │ 可以直接截取问题的前若干字,也可以让模型总结一句 │ 让模型总结更好看,但那是一次额外的收费调用(见取舍) │ ├─ 允许用户手动改名,改过之后不再自动覆盖 │ └─ 全部显示「新会话」的后果(我自己就被这个坑住了) 积累几十个之后列表是一整屏同名条目 只能一个个点开找,而我要找的往往是「上周问过的那个问题」 列表摘要(不要拉正文) │ ├─ 会话表上单独存一份截断后的摘要 │ ├─ 每次从正文截取的后果 │ 模型回答可能很长(几千字的代码解释很常见) │ 列表拉二十个会话 = 拉二十段长文本,然后在前端截断 │ 传输的绝大部分内容是被丢掉的 │ └─ 摘要要在写入时就处理好换行与代码块(否则列表里显示很乱) 消息的存储与分页 ├─ 消息表按会话与序号建索引,会话内用游标分页 ├─ 长回答直接存整段,不做拆分(拆分会让拼接与搜索都变复杂) └─ 记录每条回答的提示词版本与用量(见前面几块) 按内容搜索历史对话 │ ├─ 用全文索引实现,中文需要处理分词(和论坛项目同一套问题) │ ├─ 搜索结果要显示:会话标题 · 命中片段 · 时间 │ 只显示会话标题的后果:用户不知道是哪一句命中的 │ ├─ 点击结果要能定位到那条消息(而不是只打开会话) │ └─ 边界要说清:当前量级下全文索引够用 需要语义检索(问法不同但意思一样)时才需要别的方案 删除与导出(让用户敢删) │ ├─ 两种语义要分开 │ 删除单条消息:只影响这一条,但要注意它会改变上下文 │ 删除整个会话:连带全部消息 │ ├─ 删除单条消息后,后续轮次的上下文里也不再包含它 │ 不处理的后果:用户以为删了,但模型还「记得」 │ ├─ 提供导出为文本(会话标题 + 逐轮问答) │ 不提供的后果(我自己就是这样) │ 历史里有些回答我觉得以后可能有用 │ 删除不可逆又没有导出 → 一条都不敢删 │ 历史越积越多,列表越来越难找 │ └─ 删除要二次确认,删整个会话时回显标题 其他 ├─ 会话数量很多时提供按时间分组(今天 · 本周 · 更早) ├─ 支持置顶少数常用会话 └─ 空态区分「还没有对话」与「加载失败」
    分步拆解
    1. 必须有独立的会话表,不要从消息表分组算列表。消息表持续增长而会话表的行数增长远慢于它——这是两者的本质区别。
    2. 发消息时更新会话的摘要、更新时间、消息数,并与消息落库放在同一个事务里。分开做的后果是列表显示的最后一条和实际不一致。
    3. 列表查询按(用户, 更新时间)索引取并用游标分页。耗时不再随消息总量增长。
    4. 会话标题要自动生成,不能全部叫「新会话」。积累几十个之后列表是一整屏同名条目,只能一个个点开找。
    5. 标题允许手动改名,改过之后不再自动覆盖。用户自己起的名字比自动生成的更符合他的记忆。
    6. 列表摘要要单独存一份截断后的内容。每次从正文截取的后果是列表拉二十个会话就要拉二十段长文本,而传输的绝大部分内容被丢掉了。
    7. 摘要在写入时就要处理换行与代码块。否则列表里显示很乱——而这一步放在写入时只做一次。
    8. 消息表按会话与序号建索引,会话内用游标分页。和聊天项目同一套做法。
    9. 长回答直接存整段,不要拆分。拆分会让拼接与搜索都变复杂,而收益很小。
    10. 按内容搜索用全文索引,中文要处理分词。这和论坛项目是同一套问题——默认按空白切分对中文几乎无效。
    11. 搜索结果要显示命中片段而不只是会话标题。只显示标题的后果是用户不知道是哪一句命中的。
    12. 点击搜索结果要能定位到那条消息。而不是只打开会话让他自己找。
    13. 删除要区分「删单条消息」与「删整个会话」两种语义。它们的影响面完全不同。
    14. 删除单条消息后,后续轮次的上下文里也不能再包含它。不处理的后果是用户以为删了,但模型还「记得」。
    15. 必须提供导出。不提供的后果是用户因为担心丢失而一条都不敢删,历史越积越多、列表越来越难找——问题的根源不是删除不好用,而是没有一个安全的退路。
    16. 删除要二次确认,删整个会话时回显标题。防的是点错了行。
    17. 会话很多时提供按时间分组与置顶。今天、本周、更早——这比纯粹的时间倒序好找得多。
    关键决策与取舍

    建独立会话表,接受「同一份信息存两处」的冗余。从消息表算列表永远不会不一致(只有一份数据)。但它的耗时随消息总量增长,而会话列表是打开应用第一眼就要看到的东西冗余的代价是要保证一致性——我的做法是把更新会话与消息落库放在同一个事务里。判据是「读的频率和底层数据的增长速度」:读得最频繁、底层一直在涨,那就必须把结果预先算好存下来这和我在聊天项目里的会话列表是完全同一个判断,说明它是个通用模式。

    会话标题用「截取首轮提问」而不是「让模型总结一句」。让模型总结出来的标题明显更好看、更概括。但那是一次额外的收费调用——而标题这个东西的价值是「能让我认出这段对话」,截取首轮提问的前若干字已经足够达到这个目的代价是标题有时会很生硬(比如问题以「帮我看看」开头)缓解办法是允许手动改名判据是「这个改进值不值得一次额外调用的成本」:在我这个自费项目里不值得——而且我把这个取舍写清楚了,因为它是成本约束下的有意选择,不是没想到。

    列表摘要单独存一份,而不是每次从正文截取。从正文截取不需要额外字段、也不会不一致。但模型回答经常几千字,列表拉二十个会话就意味着传二十段长文本、然后把绝大部分丢掉代价是多一个字段、而且正文被编辑时摘要要同步更新判据是「传输的数据里有多大比例会被用到」:比例极低,那就该在写入时把要用的部分单独存好。

    提供导出,这是让「删除」变得可用的前提。我最初只想着「删除功能做好就行」。但真正的障碍不是删除不好用,而是我不敢删——历史里有些回答我觉得以后可能有用,而删除不可逆、又没有导出结果是我一条都不敢删,历史越积越多、列表越来越难找加了导出之后我才开始正常地清理历史。判据是「用户不用这个功能,是因为它不好用还是因为他不敢」:是不敢,那就该给他一个安全的退路,而不是把删除按钮做得更显眼。

    删除单条消息时同步把它从后续上下文里去掉。只删展示最简单。但用户删除一条消息的意图通常包含「不要再基于它回答」——如果模型还「记得」,那这个删除是不完整的判据是「用户执行这个操作时的完整预期是什么」:他的预期是「这条内容从此不存在」,那就要覆盖它的全部影响面。这一条和我在其他地方遇到的「撤销要还原全部影响」是同一个思路。

    踩过的坑一:会话列表全是「新会话」,我自己都找不到想要的那段对话。积累几十个之后是一整屏同名条目。教训是:一个「历史记录」功能好不好用,取决于用户能不能找到他要的那一条——而不是取决于它存得有多全我最初只关心「把对话都存下来」,结果是存得越全越没用。

    踩过的坑二:列表页把长回答的正文全拉出来了。我是看接口返回体大小时发现的——一个列表请求的返回体大得离谱教训是:列表接口要明确「这一页真正需要展示的是哪些字段」而「顺手把整条记录返回」是很容易写出来的默认行为。

    踩过的坑三:我自己不敢删历史,导致历史失去了作用。这个坑很有意思,因为功能是「完整」的(能删),但我不用它教训是:当一个功能存在但没人用时,先问「是不好用还是不敢用」——这两种情况的解法完全不同。

    没做的部分:没做语义检索(问法不同但意思一样也能搜到)。它需要向量存储和嵌入调用(又是一份持续的费用),而且检索质量的调优需要不断试取舍依据是「当前量级下全文索引配合分词已经能满足『我记得我问过含某个词的问题』这个主要场景」——而语义检索解决的是「我记得意思但想不起用词」,这个场景在我自己的使用中出现频率不高。不过我能说清它的思路与代价。

    数字是怎么测的

    会话列表耗时必须报清对话规模:「灌入数千轮对话后,会话列表接口耗时从 X 降到 Y,且改造后耗时与消息总量无关」。「与总量无关」是这个改动的核心收益,要明确写出来。

    列表返回体大小是这个模块最直观的一个数字:「一页 N 个会话的返回体大小从 X 降到 Y」并说明原因是不再返回长回答的正文这个数字可以直接在网络面板里看到,很容易核对。

    标题的效果不适合用技术指标衡量,可以报一个操作层面的观察:「改造前列表里同名条目占绝大多数、需要逐个点开查找;改造后可直接从标题定位」。诚实说明这是定性观察而不是量化指标。

    搜索用断言型用例:造一批含特定词的历史对话,断言能搜到、结果显示了命中片段、点击能定位到那条消息并断言中文分词生效(搜两个字的词能命中长句)——这和论坛项目是同一套用例。

    删除单条消息的完整性是踩坑的直接回归:删除某一轮的回答后继续提问,断言拼给模型的上下文里不包含它(直接检查请求体,不要靠观察回答)。

    删除整个会话:断言会话与其全部消息都被删除、列表中不再出现、相关的统计数据也一并处理。

    导出:断言导出的文本包含会话标题与逐轮问答、格式可读;并断言导出不受当前上下文裁剪的影响(导出的是全部历史,而不是模型看到的那部分)。后一条容易漏但很重要。

    一致性:发一条新消息后,断言会话表的摘要、更新时间、消息数与消息表一致;人为让其中一步失败,断言事务回滚、两者不会不一致。

    不要报什么:不要报「查找效率提升」——没有可靠的度量方式。该报的是「对话规模」「列表接口耗时与总量解耦」「一页返回体大小的对比」「中文分词后能搜到并定位到具体消息」「删除单条后上下文请求体中不含它」这几件带条件的、可核对的事。

    面试追问
    Q:历史对话就是把消息存下来,列表查一下最后一条,这里有什么问题? A:三个问题,而且它们都要「积累了足够多的对话」才会出现——我自己用了一段时间、又灌了几千轮对话之后才暴露。第一是列表越来越慢:我没有独立的会话概念,列表是「从消息表里找出所有会话、按会话分组、每组取最后一条」算出来的,对话积累后明显变慢——而它是打开应用第一眼就要看到的东西。改法是建独立会话表(标题、最后一条摘要、更新时间、消息数),发消息时与消息落库在同一个事务里更新它判据是「读的频率和底层数据的增长速度」:读得最频繁、底层一直在涨,那就必须把结果预先算好存下来——这和我在聊天项目里的会话列表是完全同一个判断。第二是列表返回体大得离谱:我取每个会话的最后一条消息作为摘要,而模型回答经常几千字,列表拉二十个会话就是拉二十段长文本、然后在前端截断——传输的绝大部分内容被丢掉了。改成在会话表上单独存一份截断后的摘要而且写入时就处理好换行与代码块(否则列表里显示很乱)。教训是:列表接口要明确「这一页真正需要展示的是哪些字段」,而「顺手把整条记录返回」是很容易写出来的默认行为。第三个问题最影响可用性:我的会话标题全是「新会话」,积累几十个之后列表是一整屏同名条目,我自己都找不到想要的那段对话。教训是:一个「历史记录」功能好不好用,取决于用户能不能找到他要的那一条——而不是取决于它存得有多全。我最初只关心「把对话都存下来」,结果是存得越全越没用。
    Q:删除功能你做了什么特别的处理? A:两件事:把删除的语义拆开,以及先把导出做了。先说语义——删除单条消息删除整个会话是两个不同的操作,影响面完全不同,我把它们分开了。而单条删除有一个我一开始漏掉的点:删掉之后,后续轮次拼给模型的上下文里也不能再包含它。不处理的后果是用户以为删了,但模型还「记得」——他会觉得这个删除是假的判据是「用户执行这个操作时的完整预期是什么」:他的预期是「这条内容从此不存在」,那就要覆盖它的全部影响面,而不只是把它从界面上去掉。然后是更重要的那件事:我先把导出做了,因为不做导出的话删除功能等于不存在。这个结论来自我自己的使用——我历史里有一些回答觉得以后可能有用,而删除不可逆、又没有导出,结果我一条都不敢删,历史越积越多、列表越来越难找注意这里功能是「完整」的(能删),但我不用它教训是:当一个功能存在但没人用时,先问「是不好用还是不敢用」——这两种情况的解法完全不同我的障碍是不敢,所以解法不是把删除按钮做得更显眼,而是给他一个安全的退路导出为文本(会话标题加逐轮问答),加了之后我才开始正常清理历史。导出这里还有一个容易漏的点:导出的必须是全部历史,而不是模型看到的那部分——上下文是被裁剪过的,如果按裁剪后的内容导出,用户会莫名少了一段。另外删除要二次确认、删整个会话时把标题回显出来,防的是点错行。顺带说一下检索:光有标题还不够,我补了按内容搜历史对话,用全文索引做、中文要处理分词(和论坛项目同一套问题,默认按空白切分对中文几乎无效);搜索结果要显示命中片段而不只是会话标题(只给标题的话用户不知道是哪一句命中的)、点击要能定位到那条消息边界我说清楚:当前量级下全文索引够用,它解决的是「我记得我问过含某个词的问题」;如果要解决「我记得意思但想不起用词」,那需要语义检索——要向量存储和嵌入调用,又是一份持续的费用,这个场景在我自己的使用里出现频率不高,所以没做,但我能讲清它的思路和代价。

没有匹配的内容,换个关键词试试。

项目拆解 · AI 对话工具(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据