在传统应用中,用户的大部分操作都能立刻得到结果(比如设置个人信息、搜索特定条目、删除特定条目、选购某个商品)。可是在利用模型能力的应用中,GPT-4o 和 DeepSeek-V3-0324 等传统模型要花时间长篇大论,o3 和 DeepSeek-R1 等推理模型更会带着 CoT 长篇大论。诤略参谋在学校创新项目实训的背景下诞生,需要用 DeepSeek-R1 解决问题——所以,用户在 DeepSeek-R1 碎碎念时该干什么?对着 LOADING 提示思过吗?这实在无礼。
我粗略想到三类改进方案:
- 像各大 Chatbot 一样流式输出,让用户明确感受到工作进展,并允许用户提前看已完成的部分,降低响应时间。它适合“能把完整输出 X X X 拆分成各自拥有完整语义的小输出 x 1 , x 2 , ⋯ , x n x_1, x_2, \cdots, x_n x1,x2,⋯,xn 分别展示,且在逐步构建 X X X 的过程中已展示的小输出 x 1 , x 2 , ⋯ , x k x_1, x_2, \cdots, x_k x1,x2,⋯,xk 绝不再变化”的场景。可是诤略参谋里我们希望模型格式化输出对已有计划的若干条改进建议,而且这些建议不以段落形式展示,而是类似 Office 批注形式展示。为了实现批注的视觉效果,我们只能在模型完整输出 X X X 后再用算法根据约定的结构解析(比如找到引用段落的起止索引)、分拆并渲染它。设想我们采用流式输出,那我们要渲染 x 1 , x 2 , ⋯ , x n x_1, x_2, \cdots, x_n x1,x2,⋯,xn 等非完整输出,而渲染前需要先调用算法根据约定结构解析输出,而 x 1 , x 2 , ⋯ , x n x_1, x_2, \cdots, x_n x1,x2,⋯,xn 的结构不合要求。矛盾,所以流式输出不合适。
- 降低生成耗时。推理模型的性能就是长 CoT 带来的。模型和人类都不是神,用更多的时间和“思考”换取复杂问题的更好答案天经地义,速度和质量不可得兼(即使我们想要牺牲质量,DeepSeek-R1 也暂不支持
reasoning_effort
)。在 CoT 长度和结果质量不变的前提下提高速度是模型层和 infra 层的事,给出其他范式的模型打破这一约束是产业界科学界的事……总之暂时不是我这个本事不够的人的事。 - 把生成和呈现分离。谁规定用户必须亲眼见证模型构造输出的全过程?各家的 Deep Research 还有 Manus 等 agent 已经允许用户关闭网页稍后再来领结果了。耗时没变,但不霸占用户的电脑,不强迫用户面壁思过。
我选择第三种方案。“推理模型输出慢”和“不想强迫用户面壁思过”引出“把生成和呈现分离”,“把生成和呈现分离”引出诤略参谋数据通讯系统的最后两块拼图——“任务系统”和“全局弹窗系统”。任务系统实现后台完成任务、允许用户在等待完成任务时闲逛甚至关闭网页,全局弹窗系统负责及时通知用户。当然,它们也与博客“二、数据通讯(上)”里已完成的部分协作——比如 axios 响应拦截器会根据响应中的 BizCode
和 userVisibility
等配置调用弹窗系统提供的函数为大部分响应创建弹窗,开发者不用专门处理每种操作的交互提示。
理由够充分了——让我们开始吧。
- 作者:魂兮归乡(优快云:@theColumnSpace)
- 为获取最好的阅读体验,建议在飞书平台阅读此文:三、数据通讯(下)
- 文章上篇见博客“二、数据通讯(上)”。
初识 WebClient 与 OpenAI API
设计“全局弹窗系统”和“任务系统”需要迁就 API 的调用方式和响应格式。所以在讨论这两个系统前,我们先学些新东西——怎么用 Springboot 后端向模型 API 发送请求?如何处理响应?我不想让推理模型的长篇大论阻塞 API,所以这种请求应该是异步的。那就请 WebClient 上场。
@Configuration
public class WebClientConfig {
@Value("${llm.api.key}")
private String apiKey;
@Value("${llm.api.base-url}")
private String baseUrl;
@Bean
public WebClient OpenAIWebClient() {
return WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
注:我把这个叫 OpenAIWebClient 不是因为要调用 OpenAI 提供的模型,而是因为各模型厂的接口几乎都兼容 OpenAI API,更重要的是诤略参谋使用的 One API 是 OpenAI API 格式。
这段 WebClient 配置代码可以类比为 axios 里的 axios.create(options)
。Spring 在未来会自动根据此配置类创建一个用于发送请求的 openAIWebClient
对象,这个对象的基本配置在配置类里定好,正如 axios.create
出的对象的基本配置在 options
中定好。openAIWebClient
≈ instance
,配置类 + 依赖注入 ≈ axios.create
,配置类里的代码 ≈ options
。
const instance = axios.create({
baseURL: baseUrl,
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
}
});
继续利用类比学习。instance.post("/chat/completions")
在 POST baseUrl/chat/completions
。对应到 WebClient 里是 openAIWebClient.post().url("/chat/completions")
。请求的核心是 messages
数组,数组内是形如 { role: xx, content: yy }
的 message
对象。我们编写对应的 Message
和 Messages
类。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Message {
private MessageRole role;
private String content;
}
public enum MessageRole {
SYSTEM("system"),
USER("user"),
ASSISTANT("assistant");
private final String value;
MessageRole(String value) {
this.value = value;
}
@JsonValue // 发送响应时从 enum 转为字符串
public String getValue() {
return value;
}
@JsonCreator // 接收响应时从字符串转回 enum
public static MessageRole fromValue(String value) {
for (MessageRole role : MessageRole.values()) {
if (role.value.equalsIgnoreCase(value)) {
return role;
}
}
throw new IllegalArgumentException("未知角色:" + value);
}
}
Jackson 是 Spring 默认的 JSON 序列化器。@JsonValue
注解会在发送请求时把枚举类转为其 value
(字符串)以满足 API 的数据类型要求。比如,MessageRole.USER
在请求 JSON 中是 "user"
。
用户和模型“你一言我一语”过程中会生成若干 Message
对象,这些对象会按时间顺序存在数组里被组织成“会话记录”。我用 Conversation
类实现这个会话记录数组。
@Data
@AllArgsConstructor
public class Conversation {
private String model;
private List<Message> messages;
private boolean stream = false;
private double temperature = 0.7;
public Conversation() {
this.model = "DeepSeek-R1";
this.messages = new ArrayList<>();
}
public void appendSystemMessage(String content) {
messages.add(new Message(MessageRole.SYSTEM, content));
}
public void appendUserMessage(String content) {
messages.add(new Message(MessageRole.USER, content));
}
public void appendAssistantMessage(String content) {
messages.add(new Message(MessageRole.ASSISTANT, content));
}
@Override
public String toString() {
return String.format(
"Conversation{model='%s', messages=%s, stream=%s, temperature=%.2f}",
model, messages, stream, temperature
);
}
}
接下来编写 LLMResponse
结构,这有助于简化对 API 响应的处理。首先我们阅读 OpenAI API 的文档,看看响应(Chat Completion Object,下文简称“CCO”)包含哪些我们关心的内容。
- 发送请求时有一个
n
参数,默认为1
,表示一次 API 调用中根据请求里的输入生成平行的n
份模型输出。对应 CCO 的choices[0 ~ n-1]
。诤略参谋里没有平行输出的需求,所以一直盯着CCO.choices[0]
即可。 usage
与choices
数组同级(CCO.usage
),是各种统计数据。如果n > 1
,它会把所有输出合起来统计,而不是对每个输出单独统计。- 包括
prompt_tokens
、completion_tokens
、total_tokens
、prompt_tokens_details
和completion_tokens_details
。其中completion_tokens_details
下含reasoning_tokens
。OpenAI 建议至少为completion_tokens
保留两万五的空间。
- 请求体里的
max_tokens
和max_completion_tokens
控制的都是completion_tokens
(= CoT + 非 CoT)的上限。但这是硬性约束,模型不会先看到这个参数然后自动调整自己的 CoT 和回答篇幅以保证不越界,而是依旧我行我素无视这个约束,然后碰壁(所以别想用这个实现reasoning_effort
的效果)。此外n > 1
时它们控制的是单独每一个输出的上限,不是所有输出总和的上限。- DeepSeek 官方 API 用
max_tokens
(默认4096
)限制completion_tokens
。测试表明使用 One API 调用 R1 时应该用max_tokens
。
- DeepSeek 官方 API 用
- 包括
- 一个
choice
里包括模型的输出(choices[0].message
)和输出的完成原因(choices[0].finish_reason
)。完成原因的取值为stop
表示正常结束,length
表示因到达上下文窗口限制或completion_tokens
到达max_completion_tokens
结束,content_filter
表示被审核干掉了。 message
包括role
和content
。- OpenAI 对 CoT 严防死守,OpenAI API 里没有
reasoning_content
这种东西。 - DeepSeek 官方 API 在
message
里有额外的reasoning_content
显示 CoT。 - 学校利用的 One API 旨在“通过标准的 OpenAI API 格式访问所有的大模型”,所以也没有
reasoning_content
!CoT 直接写在content
里了!
- OpenAI 对 CoT 严防死守,OpenAI API 里没有
现在我们设计与响应格式对应的 Java 类,以便未来处理响应。
现在我们学习一下 Web Client 的基础知识。LLM 告诉我我可以这样发送一个同步请求:
public String syncChat(Conversation conversation) {
CCO cco = openAIWebClient
.post()
.uri("/chat/completions")
.bodyValue(conversation)
.retrieve()
.bodyToMono(CCO.class)
.block();
if (cco == null || cco.getChoices() == null || cco.getChoices().isEmpty()) {
throw new RuntimeException("LLM 没有返回有效的响应!");
}
return cco.getChoices().get(0).getMessage().getContent();
}
我们可以把这一长串调用链划分成两段:
.post().uri("/chat/completions").bodyValue(conversation).retrieve().bodyToMono(CCO.class)
:执行这些函数时没有真正开始发送请求,只是在规划请求方法、目的地、请求体(bodyValue
)、将来收到的响应体长什么样子(retrieve().bodyToMono(CCO.class)
)。bodyToMono(CCO.class)
表示将来收到的响应体长成CCO
类的样子。Mono
可以类比为Promise
,Mono<T>
表示未来某个时刻会有个T
类对象作为“fulfilled 值”。
.block()
:相当于同步阻塞版本的Promise.then(data => data)
(Mono<CCO>
类对象对应这个Promise
)。一旦被调用就发送请求、等待响应,请求和响应时是阻塞的。WebClient 既能同步请求也能异步请求,我们先试验简单的同步请求。
我们先测试一下现有的代码:
成功了。意料之中的慢,慢到最初的 axios 配置因为超时主动掐断了请求。
任务系统
我真心觉得强迫用户对着 LOADING 思过 59.02s 很不礼貌,所以我们该给用户按下 LLM 操作相关的按钮后用四处闲逛的方式消磨等待时间的自由,等 LLM 给出结果后我们再利用全局弹窗系统提醒用户去查看结果。总之,不管后端的行为是阻塞的还是非阻塞的,用户的感觉必须是非阻塞的,他眼中的 LLM 相关操作必须在后台而非前台执行。
为了给用户“后台”的感觉,我粗略想到了三类方案。
方案一:任务系统 + 前端轮询 + 后端 .block
同步请求
思路:不阻塞的主线程派发任务 + 阻塞的后台线程同步请求
- Spring MVC 的请求处理模型是:前端发一个 HTTP 请求 → 从 Tomcat 内置线程池中分配一个线程来处理 → 调 Controller 方法 → 返回响应后释放线程。这个线程池的默认大小是
200
。我们把 Tomcat 内置线程池里的线程叫做“主线程”。 - 我们可以专门圈出一个大小为
k
k
k 的后台线程池(不使用 Tomcat 内置线程池的资源,主线程正常工作),里面的线程专门处理同步请求。后端收到用户请求后,主线程
A
创建任务对象、把同步请求分配给后台线程池里一个线程T
处理。线程T
会被阻塞,但是主线程A
没有被阻塞,依旧可以处理正常的请求和我们考虑的轮询。 - 用户第一次提交任务使用主线程
A
,同步请求阻塞后台线程T
,轮询使用主线程X1
、X2
等,最终拿取结果的请求使用主线程B
。
问题:
- 后台线程池太容易被塞满。假设 DeepSeek-R1 给出响应需要 1 分钟,1 分钟内同一位用户足以到处闲逛并提交 3~4 个不同的 LLM 任务。这就有 3~4 个后台线程同时被堵着了。这样的用户多来几个,后台线程池里就算有几百个线程也承担不起(当然,应付课程设计检查绰绰有余,哈哈)。
- 本来就要运行数据库连接池、HTTP 请求池等一大堆池里的线程,再加上一堆后台线程的话 CPU 应接不暇。
- 每个线程都有自己的栈(默认
1~2 MB
),开一大堆线程的话内存吃不消。
变种:
- 如果后台线程池中被阻塞的线程超过阈值,对于新的请求直接在主线程里同步请求。原来是用户不知道要排队排多久,现在是用户要干瞪眼但不需要排队,选一个吧。
方案二:任务系统 + 前端轮询 + 后端 .subscribe
异步请求
把方案一的代码稍微换掉几行(最重要的是把 block
换成 subscribe
),然后删掉后台线程池那些代码就得到方案二了。方案二比方案一好得多,它可以在较大并发下维持高性能。我们可以想一些更细节的事了。
三个 API:
- 第一种 API 统一使用
POST /api/llm-xx
格式,xx
作为按钮参数传进来。因为诤略参谋里只有“把一堆信息扔给 LLM,让它据此生成新计划、分析报告”一类的 LLM 任务。 - 第二个 API(轮询 API)就是
GET /api/task/id
,id
由后端响应给出,URL 可以自动拼出来。无论提交的是什么请求、要 LLM 帮你干什么事,我们这里只关注任务进行状态,而所有任务的格式都统一,所以我们可以只用一个 API。 - 第三种 API 统一使用
GET /api/xx/id
形式。id
由后端响应给出,xx
直接复用用户给按钮传的xx
,这个 URL 也可以自动拼。有件事很棒——这种 API 的语义非常和谐,即使没有任务系统这些 API 也可以正常被调用,URL 的语义也非常正确和清晰。
后面两种请求对用户都是透明的。没错,只需要给按钮传一个 xx
,完全可以复用 getBtnSender
HOF 和 Btn
。如何触发轮询和最终成果接收?我们只需要写一份前端 task
store,这个 store 内有负责这些的函数和维护对应信息的数据结构,把触发轮询的函数暴露出去、在 Btn
的 whenReady
里调用即可。这个函数需要把响应中的任务信息推进数据结构里,而这个任务信息……正好是 whenReady
的实参 data
!很漂亮。我们正在从博客“二、数据通讯(上)”中一直追求的统一里获得回报!
控制对应按钮的禁用:
- 给
Btn
加个id
属性,要求开发者们保证全局唯一(列个在线共享表格记录下每个页面的 LLM 相关Btn
用了什么id
,自己要写新id
前搜索一下表格里是否已有此id
。很简单,不会乱套的)。 - 就像
user
store 里把userId
的值作为users
里的键一样,因为在任务完成前Btn
禁用,所以task
store 的工作任务中可以把Btn
的id
作为键,把这个Btn
提交的、正在处理的任务对象作为对应的值。记录了正在等待完成的各任务的来源Btn
,也恰好满足主键唯一性。 task
store 给Btn
提供一个函数,利用响应式的值动态判断工作队列里有无键为对应Btn
id
的任务对象。如果有,那个Btn
通过这个函数知道自己现在处在禁用态。
很干净。
但是我还是要抬杠:
- 当 WebClient 发出一个 HTTP 请求时,它需要和服务端建立 TCP 连接。如果每个请求都新建连接,然后请求完就断掉,那成本太高,性能低。所以使用连接池复用之前用过的连接,省去重复建立连接、握手、关闭的开销。
- 虽然异步请求不占用线程,但它要打开 TCP socket 链接、建立 HTTP 请求流通道、在后台监听这个连接的数据返回。而这些会持续占用连接池里的连接资源,直到响应到来。所以连接池可能会满(比方案一的池满难达成得多)。
- Reactor 会缓存未完成的请求,如果请求像潮水一样发出(比如 1s 内向 LLM API 发 100 个请求),而响应来得慢(一分钟后突然来一堆响应),内部队列会被占满。
方案三:任务系统 + SSE + 后端 .subscribe
异步请求
SSE 中,后端持续主动地向浏览器汇报信息,浏览器只需要设置事件监听器监听信息,让事件处理函数在关心的信息到来时执行。换句话说,不需要轮询就能知道任务完成或失败,节省了浏览器和后端的轮询开销。
SSE 的本质是个长连接的 GET
请求。但是它有一些限制:
- 只能用
EventSource
发送,不能使用 axios。发送 SSE 本身极其简单,不能用 axios 也没什么。 EventSource
是浏览器内置的对象,不能手动添加请求头,所以会和JwtAuthFilter
打架。但是我们可以归约:在JwtAuthFilter
前额外加入一个自定义过滤器,这个过滤器在请求阶段工作。让EventSource
在请求参数里携带jwt
(http://localhost:9090?jwt=...
),这个过滤器会把请求参数里jwt
的值塞到Authorization
中。- 这个请求只期望返回
text/stream
类型的数据。如果JwtAuthFilter
的 JWT 校验没通过,“二、数据通讯(上)”中的FilterSecurityInterceptor
会抛出异常,异常会被ExceptionTranslationFilter
捕获、到达自定义入口点、被转发到/error
、返回 JSON 格式数据,然后前端的onerror
就触发了,报错。由于 SSE 的协议限制,我们看不到有关错误的任何信息,无法区分这是真的错误还是 JWT 没通过。可以通过在入口点里分类讨论、对 SSE JWT 不通过情况直接给出响应解决。
我一开始不知道 SSE,GPT-4o 跟我提到了 SSE,我看到 send
和 onmessage
时很高兴——GPT-4o 跟我说 SSE 的代码极简单,我看它给出的代码也是真的很简单。即使有上述障碍,也都不难解决。然后随着越聊越深……我发现我被 GPT-4o 骗进来杀了。SSE 需要专门进行连接管理、身份隔离,要维护连接映射表。
所以诤略参谋项目里不会采用 SSE 了,即使它性能更好。我没有精力了,留给未来的项目吧。我们采用方案二。
打磨细节
在写代码前,我们还要继续完善设计细节。让我们先思考以下问题……
抢先删除问题
诤略参谋涉及的 LLM 任务如下:
- 在特定项目下让 LLM 生成一个计划。生成结果依赖于项目。项目内容发生变更时,已生成的计划不变,只影响新生成的计划。
- 在特定计划下让 LLM 生成一个 mermaid 流程图或预算/风险分析。生成结果依赖于计划。计划内容发生变更时在按钮角落警告用户对应的 LLM 生成结果过时,在专门的结果展示页面显示“因为计划内容在这份××给出后发生了变更,该××可能已过时,建议重新生成××”的常驻提示。可以通过给计划增加时间戳或版本号实现(
plan_version
、upgrade_version
、mermaid_version
、analyze_version
、reforge_version
),每次保存对计划的编辑都plan_version++
,是否出现警告则由对上面各值的判等决定。 - 在特定计划下让 LLM 生成一个计划改进方案。生成结果强烈依赖于计划,只要
plan_version > upgrade_version
,这些改进方案就要全部禁用。当用户生成多次改进方案时,新方案会覆盖旧方案,总之一个计划在同一时刻只有至多一个改进方案。用户只能查看改进方案,不能删除或编辑它。 - 在特定计划下让 LLM 根据计划和用户选择的、未被禁用的改进方案子集生成重写的计划。生成结果依赖于计划和改进方案。计划内容发生变更时,已有的重写计划不变,只影响新生成的计划。如果
plan_version > upgrade_version
,不允许重写计划,必须先生成同版本号的改进方案。
我们可能面临的问题:
- 用户在异步请求把 LLM 生成的计划存入数据库前就删除了对应的项目。
- 用户在异步请求把 LLM 生成的 mermaid 流程图、预算/风险分析或改进方案存入数据库前就删除了对应的计划。
- 用户在异步请求把 LLM 生成的计划改进方案存入数据库前就更新了计划,导致改进方案刚出生就禁用。
- 解决方案:改进方案里存一份提交任务时的计划内容副本,无论版本号最新与否都允许用户查看这个改进方案,但是
plan_version > upgrade_version
时不允许应用此改进方案,提醒用户此方案已过时,需要重新生成。
- 解决方案:改进方案里存一份提交任务时的计划内容副本,无论版本号最新与否都允许用户查看这个改进方案,但是
- 用户在异步请求把重写的计划存入数据库前就删除了计划。
- 用户在异步请求把根据旧计划内容重写的计划存入数据库前就更新了计划。
- 解决方案:让用户自行取舍是否使用重写的计划覆盖当前计划。另外只要
plan_version
或upgrade_version
大于reforge_version
,就不允许在重写计划页面使用“重新生成”功能。
- 解决方案:让用户自行取舍是否使用重写的计划覆盖当前计划。另外只要
任务系统需要解决 1、2、4——用户可以在获得结果前抢先删除结果在数据库中的依赖项的问题。其实很简单……异步请求在存入数据库前查询一下对应 id
的依赖项是否存在,不存在的话直接把任务的状态设为失败。
任务消失问题
用户在将来大概会在右上角有一个任务面板,可以在那里直接查看当前处理中的任务、失败的任务和成功的任务(在 task
store 中对应三个队列)。处理中的任务全部显示即可,可剩下两种任务我既希望用户能在面板里看到(以免用户没看清任务出结果时的短暂弹窗提示),又不希望它们无限堆积。
- 给任务加入“完成时间戳”。
- store 初始化、向后端请求任务信息时,对已完成和失败的任务,只查询最近 5 条。
- store 把任务从工作队列移到完成或失败队列时,先检查对应队列的情况,如果这个任务会成为第六条,就先移出最旧的一条任务。
一键路由问题
用户在等待……用户在游荡。我希望弹窗提示任务成功时能够一键路由到结果展示页面。
我会给任务成功弹窗上加一个按钮,这个按钮在点击时会调用 router.push({name: task.routerPathName, params: task.routerParams })
实现一键路由。
前端的每条 URL 都会有独特的 name
和 props
,比如:
- 在
projectDetail.vue
组件中查看特定项目详情:- 完整路径:
/app/projects/:projectId。name = "project-detail"
。 router.push({name: 'plan-detail', params: { projectId: '123' })
。
- 完整路径:
- 在
planDetail.vue
组件中查看特定项目详情:- 完整路径:
/app/projects/:projectId/plans/:planId。name = "plan-detail"
。 router.push({name: 'plan-detail', params: { projectId: '123', planId: '456' }})
。
- 完整路径:
所以一键路由要求后端任务存储 String routerPathName
和 routerParams
对象。流程如下所述:
- 在后端为具体任务的异步请求编写具体的成功回调,成功回调函数体内设置任务对象的
routerPathName
。值必须与前端对应结果渲染页面的路由 URLname
一致。 - 成功回调函数体内设置任务对象的
String routerParams
。首先创建Map<String, Long>
(xx: id
),根据前端 URL 设计的params
向内放入键值对。之后调用任务对象的setRouterParams
方法将这个Map
对象序列化为字符串存入数据库。 - 轮询
GET /api/task/{taskId}
发现任务成功,/api/task/{taskId}
用taskId
找出对应的任务实体类对象task
,在StdResponse
里返回new TaskVO(task)
。TaskVO
与任务实体类在结构上的唯一不同之处在于TaskVO
的routerParams
类型是Map<String, Long>
(通过task.getRouterParams()
反序列化)。 - 前端收到
TaskVO
对象,直接存到task
store 里用即可。
注意:
- 这些逻辑都由后端代码负责,前端没有向后端传递任何有关路由的信息。
- 异步请求的成功回调既负责根据响应生成结果,也负责更新任务对象的状态。
除了一键路由功能外,另一个进入结果显示面板的方法是点示意图里这些红色“详情”按钮。
如果查看结果的途径只有一键路由一条的话,我们可以只在用户点击路由按钮后才向后端请求结果数据。可是存在上图这类概要界面,这些界面需要实时更新。我们可以给前端任务对象加一个 whenSucceed
钩子,它在任务完成后被任务系统自动调用。我们给任务增加 resultId
属性,把原来调用第三种 API 的地方换成调用 whenSucceed(resultId)
。开发者可以手动在 whenSucceed
里利用任务系统传入的 resultId
GET /api/xx/id
并把获取的结果加入与这些页面的渲染有关的 store 里,对应的路由页面可以先判断一下 store 里是否已经有了对应 id
的对象,如果有就不必再发请求了。
如果我们删除了任务对应的结果(比如 LLM 生成的计划),删除行为会在按钮的 whenReady
钩子里把对应条目移出 store 的数据结构,对应的“详情”按钮就会消失,这种路由我们不用操心。
为了处理弹窗里一键路由的情况,我们要给这些 LLM 生成的结果(比如计划)加一个 from
属性(默认值为 0
)记录导致了这个结果的任务的 id
。用于删除这些结果的 API 在后端要根据 from
找到对应的任务并删除之,再删除结果本身。前端要在 task
store 的完成队列中寻找这个任务对象,也移除它,与结果相关的 store 也要更新。删除这些结果的依赖项时(比如删除整个项目时)要级联删除所有 from
指向的任务。删除结果时如果 from == 0
(比如任务可以由 LLM 生成,也可以用户纯手动编写,后者不涉及任务对象),不作处理。
任务状态管理
任务实体类与初始化、轮询用 API
首先设计 Task
实体类。其中 btnBkey
对应方案二中提到的按钮 id
,taskStatus
可以取 PROCESSING
、SUCCEED
和 FAILED
三种值。我没有建立与用户表的联系,因为我认为从安全上下文中获取 userId
、再直接拿 userId
查询任务要更高效。
@Entity
@Table(name = "task")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long taskId;
private String bkey; // 任务来自哪个前端 TaskBtn
private String taskTitle; // 任务标题(”语义审核“、“生成计划”、“绘制流程图”……)
private String taskDescription; // 如”参谋正在为项目 X 编写计划“
private Long userId; // 我决定手动记录外键关系,因为实在没必要先把整个 user 对象拿出来再查指定 user 的相关信息
private boolean userVisible = true; // 是否弹窗提示用户(弹窗类型:任务提示)
// false 的任务是系统自用的,比如语义审核,用户不应知道存在这种任务;true 的任务会出现在前端任务列表,且具有弹窗提醒
private String routerPathName; // 成功时一键路由 router.push({ name, params }) 用的 name 字符串
private String routerParams; // 成功时一键路由 router.push({ name, params }) 用的 params 对象(String: Long)
private Long resultId; // 任务系统在任务成功后自动调用附着在对应任务对象上的 whenSucceed 钩子,whenSucceed(resultId),开发者可以利用 resultId 做事
@Enumerated(EnumType.STRING)
private TaskStatus taskStatus = TaskStatus.PROCESSING;
private String taskErrorMsg = null; // 任务失败的详细原因,轮询后阶段请求返回的 StdResponse.fail 会拿出这个消息用
private String taskBornAt = Utility.now(); // 给用户看的,“任务开始于”
private String taskResolvedAt; // 用户看,系统也用,“任务完成于”
// 把 Map<String, Long> (比如 "projectId": 1, "planId": 14)转为字符串,数据库里只存字符串
public void setRouterParams(Map<String, Long> params) {
this.routerParams = serializeMap(params);
}
public Map<String, Long> getRouterParams() {
return deserializeMap(this.routerParams);
}
private static String serializeMap(Map<String, Long> map) {
if (map == null) return null;
try {
return new ObjectMapper().writeValueAsString(map);
} catch (JsonProcessingException e) {
throw new RuntimeException("Map 序列化失败", e);
}
}
private static Map<String, Long> deserializeMap(String json) {
if (json == null) return new HashMap<>();
try {
return new ObjectMapper().readValue(json, new TypeReference<Map<String, Long>>() {});
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 解析失败", e);
}
}
}
然后设计 /api/task
下两个 API,一个用于轮询,另一个用于 task
store 初始化。
@GetMapping("/{taskId}")
public StdResponse getTasks(@PathVariable Long taskId) {
Optional<Task> taskOptional = taskRepository.findById(taskId);
if (taskOptional.isEmpty()) throw new BizException(BizCode.TASK_NOT_FOUND);
Task task = taskOptional.get();
return StdResponse.operationSuccess(BizCode.TASK_SYNC_UP, new TaskVO(task));
}
@GetMapping("/init")
public StdResponse getInitialTasks() {
Long myId = userService.getCurrentUser().getUserId();
List<TaskVO> processingTasks = fromTaskToTaskVOList(taskRepository.findByUserIdAndTaskStatusOrderByTaskBornAtDesc(myId, TaskStatus.PROCESSING));
List<TaskVO> succeedTasks = fromTaskToTaskVOList(taskRepository.findLatestByUserAndStatus(myId, TaskStatus.SUCCEED, maxSucceedTasksQueueLength));
List<TaskVO> failedTasks = fromTaskToTaskVOList(taskRepository.findLatestByUserAndStatus(myId, TaskStatus.FAILED, maxFailedTasksQueueLength));
return StdResponse.operationSuccess(BizCode.TASK_SYNC_UP, new InitialTasksVO(processingTasks, succeedTasks, failedTasks));
}
task
store 与轮询
接下来是前端的 task
store。先不考虑按钮禁用问题。
state: () => ({
allTasks: new Map(), // 用 taskId: 任务对象的形式存储任务的具体信息,Map 查询快一些
processingTasks: [], // 工作任务队列
succeedTasks: [], // 成功任务队列
failedTasks: [], // 失败任务队列
isPolling: false, // 控制轮询与否的标志位
pollingTimer: null,
})
轮询的核心是 poll
。
- 每被调用一次代表一次轮询,一次轮询里会向后端依次询问一遍工作任务队列中的任务的状态。
- 在一次轮询的末尾会利用
setTimeout
间接地在POLLING_INTERVAL
后调用自身,进行下一轮询问。
async poll() {
if (this.processingTasks.length === 0) {
this.stopPolling();
return;
}
const idsToCheck = [...this.processingTasks];
for (const taskId of idsToCheck) {
const task = await http.get(`/task/${taskId}`);
if (task) {
if (task.status === "SUCCEED") {
// 任务成功
this.moveToSucceed(task);
} else if (task.status === "FAILED") {
// 任务失败
this.moveToFailed(task);
}
} else {
// 出现了网络异常等
this.stopPolling();
return;
}
}
this.pollingTimer = setTimeout(() => this.poll(), POLLING_INTERVAL);
},
startPolling() {
this.isPolling = true;
this.poll();
},
stopPolling() {
this.isPolling = false;
clearTimeout(this.pollingTimer);
this.pollingTimer = null;
}
init()
会在“登录”后(详见“二、数据通讯(上)”)被调用。
async init() {
this.allTasks.clear();
this.processingTasks = [];
this.succeedTasks = [];
this.failedTasks = [];
const initialTasks = await http.get("/task/init");
const { processingTasks, succeedTasks, failedTasks } = initialTasks;
const tasks = [...processingTasks, ...succeedTasks, ...failedTasks];
if (initialTasks) {
tasks.forEach((task) => {
this.allTasks.set(task.taskId, task);
if (task.status === "PROCESSING") {
this.processingTasks.push(task.taskId);
} else if (task.status === "SUCCEED") {
this.succeedTasks.push(task.taskId);
} else if (task.status === "FAILED") {
this.failedTasks.push(task.taskId);
}
});
}
// 开启轮询
if (this.processingTasks.length > 0) this.startPolling();
}
submitTask
会由 store 对外提供,外部代码传入一个后端返回的任务对象和轮询成功后由任务系统主动执行的 whenSucceed(resultId)
钩子。
async submitTask(task, whenSucceed) {
// 从 StdResponse 中获取任务对象,然后给它额外加上 whenSucceed 钩子
// whenSucceed(resultId),一般是开发者在钩子内利用任务系统传入的 resultId GET /api/xx/resultId,然后通过更新对应 store 内状态局部刷新页面
// 任务对象加入状态池
const { taskId } = task;
task = { ...task, whenSucceed }; // 把 whenSucceed 函数加入前端任务对象(后端对应的任务对象上没有这个钩子)
this.allTasks.set(taskId, task);
this.processingTasks.push(taskId);
if (!this.isPolling) {
this.startPolling();
}
}
whenSucceed(resultId)
钩子在任务系统利用 moveToSucceed
将任务对象移动到成功队列时被执行,这时还会调用弹窗提醒用户。
moveToSucceed(task) {
const id = task.taskId;
// 1) 从 processingTasks 移除
this.processingTasks = this.processingTasks.filter((t) => t !== id);
// 2) 移入成功队列(如果已满,先干掉最旧的)
if (this.succeedTasks.length >= MAX_SUCCEED_TASKS_QUEUE_LENGTH) {
const oldest = this.succeedTasks.shift();
this.allTasks.delete(oldest);
}
this.succeedTasks.push(id);
// 3) 调用 whenSucceed 钩子
const obj = this.allTasks.get(id);
if (obj?.whenSucceed) {
obj.whenSucceed(task.resultId);
}
// 4) TODO 调用弹窗
// 5) 更新任务对象
this.allTasks.set(id, task);
}
TaskBtn
我们可以继续改造 Btn
,但是我觉得再这么下去“接口实在太宽”。于是复制一份 Btn
文件,改为 TaskBtn
。两种按钮在语义上差别很清晰。其实我不该这个时候复制,因为 Btn
的样式还没设计,到时候我要大改两份文件……不过随便吧。
- 加入
bkey
属性。之后判断禁用的代码会用到。 - 我们主要修改原来的
send
函数。现在sender
的作用是提交请求(比如POST /api/llm/plan
)。诤略参谋里 LLM 的输出需要用户提供的上下文(项目里只有“LLM 对着提供的文本给出分析”的场景,没有无中生有的场景)。所以formData
含义不变,但是要额外加上bkey: props.bkey
。此外我们已经分析了第一类 API 的格式是POST /api/llm/xx
,所以可以干掉回调函数sender
属性,改成target
。 - 原来的
whenReady(responseData)
也可以被干掉,因为这种情况下只能得到任务对象,接下来只能submitTask
。给TaskBtn
设置新钩子whenSucceed(resultId)
。 - 根据语义,把
send
改成setTask
。
async function setTask(target) {
// 判断禁用 ...
// 准备请求体
const formData = { bkey: props.bkey };
relatedFields.value.forEach((f) => { formData[f.fkey] = f.fvalue.value; });
// 发送请求
const task = await http.post(`/llm/${props.target}`, formData);
// 获取任务对象
if (task === null) return; // 异常情况,已由拦截器处理
// 把任务对象及成功回调加入工作队列
taskStore.submitTask(task, props.whenSucceed);
}
我对任务系统进行了测试,工作正常。现在补上 TaskBtn
的禁用与解禁机制。把原来的 isUnderProcessing
改为 isSettingTask
,再设置一个新的 computed
isUnderProcessing
利用 task
store 中的任务对象的 bkey
值即可。
const isSettingTask = ref(false);
const isUnderProcessing = computed(() => {
return (
isSettingTask.value ||
taskStore.processingTasks.some((taskId) => {
const task = taskStore.allTasks.get(taskId);
return task && task.bkey === props.bkey;
})
);
});
async function setTask() {
// ...
isSettingTask.value = true;
// 创建 Task
const formData = { bkey: props.bkey };
relatedFields.value.forEach((f) => { formData[f.fkey] = f.fvalue.value; });
const task = await http.post(`/llm/${props.target}`, formData);
if (task === null) return;
isSettingTask.value = false;
}
总结
本项目中常见的异步请求流程
下面的几节基本与实际项目代码无关。我提到它们是因为我需要用这些简单模型熟悉一下流程。
一次请求即解决
public void asyncChatAllInOnce(Conversation conversation, Task task, SuccessCallback successCallback) {
openAIWebClient
.post()
.uri("/chat/completions")
.bodyValue(conversation)
.retrieve()
.bodyToMono(CCO.class)
.subscribe(
response -> {
successCallback.apply(response, task);
taskRepository.save(task);
},
err -> {
task.markFail(errorHandleCallback(err));
taskRepository.save(task);
}
);
}
private String errorHandleCallback(Throwable err) {
System.out.println("执行错误回调");
String errorMessage;
if (err instanceof WebClientRequestException) {
errorMessage = "网络请求失败,请稍后重试";
} else if (err instanceof WebClientResponseException) {
errorMessage = "HTTP 错误";
} else if (err instanceof TimeoutException) {
errorMessage = "请求超时,请检查网络连接";
} else {
errorMessage = "未知错误,请稍后再试";
}
return errorMessage;
}
诤略参谋中的大部分 LLM 任务都是一次请求就搞定的,最大的不同仅仅是提示词以及结果属于哪一种实体类对象。我们可以用 asyncChatAllInOnce
搞定这类任务。
conversation
是 Chat Completion 时的请求体。核心是其中的上下文和最后一条提示词。task
是异步任务对应的对象,任务成功或失败时回调函数需要更改任务对象的taskStatus
,以便下一次轮询利用任务表发现任务完成。SuccessCallback
是我自定义的函数式接口,其中是void apply(CCO response, Task task)
。也就是说,允许传一个形参为CCO response
和Task task
的回调函数。successCallback
负责根据响应生成结果和更新任务状态,如果结果涉及用户,可以利用task.getUserId()
。errorHandleCallback
接收一个异常对象、返回字符串,负责把异常消息转为用户稍微能看得懂一点儿的消息。
这个函数简化了大部分情况下的 LLM 任务处理流程,我们只需要关心 successCallback
即可。
串行
public void asyncRequestChain() {
// 第一个异步请求 A
openAIWebClient.get()
.uri("/api/task/a")
.retrieve()
.bodyToMono(String.class)
.flatMap(responseA -> {
System.out.println("请求 A 返回: " + responseA);
// 使用请求 A 的结果发起请求 B
return openAIWebClient.get()
.uri("/api/task/b?param=" + responseA)
.retrieve()
.bodyToMono(String.class);
})
.flatMap(responseB -> {
// ...
return openAIWebClient.get()
.uri("/api/task/c?param=" + responseB)
.retrieve()
.bodyToMono(String.class);
})
.subscribe(
responseC -> { // ...
},
error -> { // ...
}
);
}
把 flatMap
视作一个“必须返回 Promise
类对象的 then
即可。它的回调函数实参就是“Promise
的 fulfilled 值”。必须有 subscribe
,因为 flatMap
只是在说“我们计划一步步这样做”——没有真的去做,subscribe
才负责执行流、触发各个回调。此外,subscribe
的错误回调负责捕获链中所有异常。
并行
public void asyncMultipleRequests() {
// 1. 先描述好 N 个请求的结构
// 异步请求 A
Mono<String> requestA = openAIWebClient.get()
.uri("/api/task/a")
.retrieve()
.bodyToMono(String.class);
// 异步请求 B
Mono<String> requestB = openAIWebClient.get()
.uri("/api/task/b")
.retrieve()
.bodyToMono(String.class);
// 异步请求 C
Mono<String> requestC = openAIWebClient.get()
.uri("/api/task/c")
.retrieve()
.bodyToMono(String.class);
// 2. 使用 Mono.zip 并发,全部成功后触发成功回调,有任何一个异常就触发错误回调
// result 是各个请求的响应(fulfilled 值)组成的一个 N 元组 (t1, t2, t3, ..., tn)
// 用 result.getTN() 取出其中特定响应
Mono.zip(requestA, requestB, requestC)
.subscribe(
result -> {
String responseA = result.getT1();
String responseB = result.getT2();
String responseC = result.getT3();
// ...
},
error -> {
System.out.println("某个请求失败: " + error.getMessage());
}
);
}
先并行,再利用并行结果串行
public void asyncRequestWithParallelThenSerial() {
Mono<String> requestA = openAIWebClient.get()
.uri("/api/task/a")
.retrieve()
.bodyToMono(String.class);
Mono<String> requestB = openAIWebClient.get()
.uri("/api/task/b")
.retrieve()
.bodyToMono(String.class);
Mono<String> requestC = openAIWebClient.get()
.uri("/api/task/c")
.retrieve()
.bodyToMono(String.class);
Mono.zip(requestA, requestB, requestC)
.flatMap(result -> {
String resultA = result.getT1();
String resultB = result.getT2();
String resultC = result.getT3();
// 根据 A、B、C 的结果串行执行 D
return openAIWebClient.get()
.uri("/api/task/d?paramA=" + resultA + "¶mB=" + resultB + "¶mC=" + resultC)
.retrieve()
.bodyToMono(String.class);
})
.subscribe(
responseD -> {
// 请求 D 完成后的处理 ...
},
err -> {
// ...
}
);
}
自动重试
public void asyncChatWithRetry(Task task, Conversation conversation, int maxRetries) {
openAIWebClient
.post()
.uri("/chat/completions")
.bodyValue(conversation)
.retrieve()
.bodyToMono(CCO.class)
.retryWhen(companion -> companion
.filter(t -> t instanceof LLMOutputFormatException)
.doOnNext(t -> System.out.println("Retrying...")) // 每次重试时执行某种副作用
.take(maxRetries) // maxRetries = 最大重试次数
.delayElements(Duration.ofSeconds(2)) // 延迟 2 秒后重试
)
.subscribe(
response -> {
// 校验响应格式
if (isValidResponse(response)) {
// 如果响应格式有效,继续处理
handleValidResponse(response);
} else {
throw new LLMOutputFormatException("Invalid response format");
}
},
err -> { }
);
}
retryWhen
用于配置自动重试。Mono
出现异常会调用对应的 retryWhen
。如果重试了 maxRetries
次后仍抛出异常,进入 subscribe
的错误回调进行最终异常处理。
retryWhen
接收一个控制重试逻辑的Function<Flux<Throwable>, Flux<?>>
回调函数。- 用条件过滤
filter
控制仅在出现指定异常时触发重试(比如LLMOutputFormatException
)。如果不写,任何异常都会重试。 - 并发时,所有自动重试完成后才返回元组。
串行四个异步请求,对其中两个配置自动重试的例子:
public void asyncRequestWithSelectiveRetry() {
Mono<String> requestA = openAIWebClient.get()
.uri("/api/task/a")
.retrieve()
.bodyToMono(String.class);
Mono<String> requestB = openAIWebClient.get()
.uri("/api/task/b")
.retrieve()
.bodyToMono(String.class)
.retryWhen(companion -> companion
.filter(t -> t instanceof WebClientRequestException)
.take(3)
.doOnNext(t -> System.out.println("Retrying B..."))
);
Mono<String> requestC = openAIWebClient.get()
.uri("/api/task/c")
.retrieve()
.bodyToMono(String.class);
Mono<String> requestD = openAIWebClient.get()
.uri("/api/task/d")
.retrieve()
.bodyToMono(String.class)
.retryWhen(companion -> companion
.filter(t -> t instanceof WebClientRequestException)
.take(2)
.doOnNext(t -> System.out.println("Retrying D..."))
);
requestA.flatMap(resultA -> {
System.out.println("处理 A:" + resultA);
return requestB;
})
.flatMap(resultB -> {
System.out.println("处理 B:" + resultB);
return requestC;
})
.flatMap(resultC -> {
System.out.println("处理 C:" + resultC);
return requestD;
})
.subscribe(
resultD -> {
System.out.println("处理 D:" + resultD);
},
error -> {
System.out.println("发生错误:" + error.getMessage());
}
);
}
LLM 异常情况
知识截止问题 KnowledgeCutoffException
经验证 DeepSeek-R1 的知识截止日期是 2023 年 12 月。如果用户说“我做外贸生意,×××,根据本周美国的关税政策生成应对计划”这类东西,DeepSeek-R1 无能为力。所以我想应该给任务失败设置 KnowledgeCutoffException
。
- 我大概会把
"当前时间是 ${LocalDateTime.now()}"
和"你的 knowledge cutoff 是 2023 年 12 月,你不知道此后发生的具体事件"
附到提示词里,要求模型先检查用户是否提及了“最近”“本周”“2025 年”“一年来”“这几天”这些词,判断用户的需求是否需要Knowledge cutoff
后的信息(比如涉及截止日期后发布的政策)。 - 然后我会要求模型按示例格式输出,其中一项会是
violateKnowledgeCutoff: true
或false
。一旦响应里这一项为true
,抛出retry
不负责的KnowledgeCutoffException
,由subscribe
的错误回调捕获后将对应任务的taskStatus
设为FAILED
并利用taskErrorMsg
用通俗的话语提醒用户因为知识截止问题模型对你的请求无能为力。 - 在前端对应页面和全局设置上设置常驻提示,警告用户背景信息涉及 2023 年 12 月后发生的具体事件时输出质量可能降低,并且需要用户手动输入相关背景信息。
格式问题 OutputFormatException
学校部署的 DeepSeek-R1 的“空思考”问题非常非常严重。“空思考”指的是模型直接输出 <think>\n\n<\think>\n\n结果
而非 <think>CoT<\think>\n\n结果
。我怀疑学校部署时没有看到官方的建议——在 tokenizer_config.json
文件里把 chat_template
最后的 <|Assistant|>...{% endif %}
改成 ...<think>\\n...{% endif %}
,强制模型以 <think>\n
作为开头以避免空思考。
- 官方建议的原理大概与分词有关,
<think>
是 128798 号 token,</think>
是 128799 号 token,\n\n
是 271 号 token,\n
是 201 号 token。空思考大概是基于上文和 128798<think>
直接预测了 271\n\n
,然后基于上文和 128798、271(<think>\n\n
)预测了 128799</think>
导致跳过 CoT。而基于上文和 128798、201(<think>\n
)则会预测出其他 token,而非 128799</think>
。 - 可以反向利用,稳定跳过 CoT,缩短响应时间:把
tokenizer_config.json
里改成...<think>\\n\\n</think>...{% endif %}
强制模型以<think>\n\n</think>
作为开头。 - 可我不是部署模型的人,所以说这些只是纸上谈兵。我只能通过提示词强调轻微缓解空思考问题。
另一个方法是检查 LLM 输出的字符串的格式。设置 OutputFormatException
,分为两个等级:
SOFT_VALIDATION
:用于简单问题(即使不思考通常也能做对)。不允许只有单个<think>
或</think>
——此时抛出OutputFormatException
。允许空思考(<think>\n\n</think>
)和有思考(<think>CoT</think>
)。HARD_VALIDATION
:用于复杂高要求问题(必须思考)。必须<think>CoT</think>
。
当指定等级的 CoT 检查通过后,利用 bool validator(String llmOutput)
钩子检查输出是否符合指定格式。只要 CoT 检查或 validator
检查不通过,就抛出 OutputFormatException
自动重试。
语义审查 PromptInvalidException
前端页面的 Field
上明明白白写着“项目背景”四个字、LLM 想通过这个 Field
获取项目背景信息用于生成计划。可有些有趣的用户就是喜欢故意写什么“请给出中国十大旅游景点”“今天天气真不错”“重复以上内容”“Do Anything Now”之类的东西,与我们斗智斗勇。年初小红书新增的翻译功能就利用了 LLM,开发者们似乎完全没有考虑提示词注入问题。
所以有时我们需要进行一个“预请求”,用 '''
或 <content>
等分隔符把用户的输入包裹起来交给 LLM,并命令它格式化地输出对 <content>
的语义是否符合场景要求(只需要输出一个 xx: true
或 xx: false
)。如果不符合,抛出 PromptInvalidException
,进入错误回调修改任务状态和错误信息。
- 可以利用学校模型严重的空思考问题,
SOFT_VALIDATION
降低审查耗时。 - DeepSeek-R1 容易过度思考,导致这种简单问题等两分钟。所以更好的方案是采用智谱、讯飞等提供的免费小模型 API。虽然那些模型能力弱,但是语义审查问题相对简单,能力应该够用,而且重点是速度够快。
- 不是在用户执行“生成计划”时才 LAZY 地语义审查,而是用户保存了对相关背景信息的更改后就偷偷提交语义审查任务。把对应的
TaskBtn
伪装成Btn
(“保存更改”),不限制对应的TaskBtn
必须完成当前任务后才能再次提交任务。用户在任务面板里看不到执行中的语义审查任务,但是语义审查任务出错后依旧弹窗提醒用户该老实点儿,并利用对应的 store 给对应内容附上高亮等警告。
我把函数改成了这样:
private String errorHandleCallback(Throwable err) {
System.out.println("执行错误回调");
String errorMessage;
if (err instanceof WebClientRequestException) {
errorMessage = "网络请求失败,请稍后重试";
} else if (err instanceof WebClientResponseException) {
errorMessage = "HTTP 错误";
} else if (err instanceof TimeoutException) {
errorMessage = "请求超时,请检查网络连接";
} else if (err instanceof OutputFormatException) {
errorMessage = "模型能力缺陷导致输出格式在多次重试后仍不满足要求,请稍后再试";
// TODO: 我想要一个通俗易懂且真的传达了信息的提示
// “参谋发疯了,过会儿再试试运气”这种确实说了这只能凭借“试试运气”,但是用户会对错因摸不着头脑
} else if (err instanceof PromptInvalidException) {
errorMessage = "参谋无法利用你给出的信息。请提供紧扣主题的信息";
} else if (err instanceof KnowledgeCutoffException) {
errorMessage = "参谋不知道 " + knowledgeCutoff + "后发生的事件,请你向他补充相关背景信息";
} else {
errorMessage = "未知错误,请稍后再试";
}
return errorMessage;
}
用 getMonoWithRetry
生成一个带自动重试机制的 Mono
对象(其实是 Flux
),可以对返回值 .subscribe
进行一轮对话,也可以用 .flatMap
先规划串行或并行路线。
private Mono<CCO> getMonoWithRetry(Conversation conversation) {
return openAIWebClient
.post()
.uri("/chat/completions")
.bodyValue(conversation)
.retrieve()
.bodyToMono(CCO.class)
.retryWhen(
Retry.backoff(maxRetryTimes, Duration.ofSeconds(retryInterval))
.filter(t -> t instanceof OutputFormatException || t instanceof TimeoutException
|| t instanceof WebClientRequestException || t instanceof WebClientResponseException)
);
}
asyncChatAllInOnce
被改为 asyncChatAllInOnceSoft
和 asyncChatAllInOnceHard
,利用 getMonoWithRetry
简化了代码:
public void asyncChatAllInOnceSoft(Conversation conversation, Task task, SuccessCallback successCallback) {
getMonoWithRetry(conversation)
.subscribe(
response -> {
String llmOutput = response.getChoices().get(0).getMessage().getContent();
if (llmOutput == null || !softCoTCheck(llmOutput)) {
throw new OutputFormatException("模型 CoT 格式不符合 SOFT 要求");
}
successCallback.apply(response, task);
taskRepository.save(task);
},
err -> {
task.markFail(errorHandleCallback(err));
taskRepository.save(task);
}
);
}
还有 asyncChatAllInOnceSoftWithValidator
和 asyncChatAllInOnceHardWithValidator
,允许传一个 Function<String, Boolean>
回调 validator
,subscribe
的成功回调会在 CoT 检测通过后截取非 CoT 部分字符串,调用 validator
检测它是否满足格式要求。
全局弹窗系统
LLM 的慢迫使我去思考后台任务,而后台任务在完成时需要提醒用户迫使我思考弹窗系统。我注意到同一网站的不同页面往往在屏幕的相同位置弹出格式一致的弹窗,于是我猜想这是否意味着存在全局弹窗系统,有几个弹窗一直被隐藏着,需要提示消息的时候调用函数把它们上面的文本数据改一下,然后取消隐藏……我分别问了 Gemini Deep Research 和 OpenAI Deep Research——全局弹窗系统确实是常见设计。设想一下,如果每个组件自己管理自己的弹窗,那么弹窗格式、位置、时机和音效很容易乱成一团,没有组件会带着全局视野去思考“这个弹窗摆在这里、那个弹窗摆在那里,这样总的视觉效果最好”。如果大家都遵守约定,只向一个“绘图者”传递一些声明,把视觉设计完全委托给绘图者,那么这个绘图者就有了全局视野——他可以汇总分析各个组件送来的声明、集中“绘图”。我想操作系统里绘制 GUI 的进程大概也是这个道理。
我有两大类方向可以选:
- 有少数几个弹窗始终存在,只是平时被隐藏。当调用弹窗函数时,没有当场新建弹窗组件,而是更换了它渲染的文本数据、取消了对它的隐藏。当“关闭”对应弹窗时,弹窗组件也没被销毁,只是重新被隐藏。
- 存在一个状态池,里面有类似“弹窗对象队列”之类的东西,根据对象的数量动态
v-for
。
前者的性能更好,但是不适合实现不定量弹窗同时堆叠显示的要求;第二类可以利用 v-for
和 flexbox
轻松实现堆叠,还可以利用 <transition-group>
为这些弹窗加入动画。缺点是每个弹窗的出现和消失都伴随着组件实例的创建和销毁。但好消息是——对于绝大多数应用场景,这点开销微乎其微,Vue 的性能优化让这个过程非常高效。只有在极端情况下(比如一秒内弹出几十上百个通知)才可能需要关注。此外为了 v-for
能利用 :key
精准移除特定弹窗,要为每个弹窗对象生成一个主键。
总体思想
谢谢响应式,全局弹窗系统会是数据通讯系统里实现起来最轻松愉悦的子系统。
Modal
GlobalModalWrapper
先做 Modal,因为 Modal 与剩余两种弹窗不同——剩余两种弹窗的布局固定,只是固定位置的数据有变动,而 Modal 的布局千变万化。“添加计划”Modal 要这些 Field
,“编辑项目”Modal 要那些 Field
……所以我们做个空壳“GlobalModalWrapper
”,在这个空壳里用 <component>
渲染在 open
方法里具体指定的 Modal 组件,用 v-bind
绑定那个组件的 props
。
GlobalModalWrapper
会监听 <component>
的 close
事件,调用 modal.close()
。
默认点击背景遮罩不会主动关闭 GlobalModalWrapper
,这是为了防止用户误触导致输入丢失。如果你想启用“点击背景自动关闭”,需要在向 open
传 props
对象时在里面额外加入 enableBackdropClose: true
。
Deletor
规定:对于删除操作,前端一律使用 http.delete('/{$resource}/{$dkey}')
代码,后端 API 一律为 @DeleteMapping("/{id}")
(使用 @PathVariable
)。基于这个规则,我们利用刚做的 Modal 实现一个可复用删除组件 Deletor
。
Deletor
本身是一个按钮,按下此按钮后调用 modal.open({component: DeleteModal, props: {...}})
弹出删除确认 Modal。我们先编写 DeleteModal
以便确认需要的 props
。DeleteModal
有警示文本,左下角是“暂不删除”按钮,右下角是“确认删除”按钮(用无 Wrapper
的单 Btn
解决)。发送 DELETE
请求的不是 Deletor
(仅用于打开确认 Modal)而是 Modal 中的“确认删除”按钮。
title
:确认删除{{ title }}
。默认值为"该条目"
,开发者应传入对应条目名,比如"计划 A"
。note
:在标题之下、“数据被删除后将无法找回,请三思而后行”之上,开发者可利用此属性补充额外信息,比如"与之关联的所有计划和参谋给出的分析将被一并删除"
。默认为空串。resource
、dkey
。其中dkey
是要删除的条目的id
的意思,叫dkey
一是为了不和自带属性id
重名,二是为了和fkey
、bkey
统一。whenReady(data)
:传给“确认删除”Btn
用的。如果前端需要在删除后更新对应 store,就用这个回调。有默认值,不需要做事的话不用传。- 注:我在博客“二、数据通讯(上)”中提到不打算用
Btn
实现删除,可在那之后Btn
已经更新了很多细节,它很适合在这个组件里被复用。
确定了 DeleteModal
后确定 Deletor
。显然它需要 title
、note
、resource
、dkey
和 whenReady
属性。此外它还需要 type
用于控制两种样式。核心逻辑……就这么简单。
<button
@click="modal.open({
component: DeleteModal,
props: {
title,
note,
resource,
dkey,
whenReady,
enableBackdropClose: true,
},
})"
>删除
</button>
Toast 与 Notification
Modal 的核心是交互,而 Toast 和 Notification 的核心是通知(同时不阻碍用户操作)。二者都基于 store 中的队列、在浮现一段时间后都会因为计时器回调被调用而自动消失。axios 响应拦截器负责根据响应的 BizCode
和 userVisibility
决定是否调用 Toast 弹窗、调用哪种弹窗提示用户(if (response.userVisibility) useToastStore().success(response.msg)
)。Toast 会浮现在屏幕中央偏上位置,如果出现连续的相同提示(比如用户疯狂点击处于“处理中”状态的 Btn
),不会产生新的 Toast,而是在最后一条 Toast 上附加 ×N
标志(无标志 → ×2
→ ×3
→ ×4
→ …)并重置那条 Toast 的消失计时器。
Toast 的核心是 toast
store。
function addToast({ text, type, duration }) {
const lastToast = toasts.value[toasts.value.length - 1];
// 如果与最后一条 toast 相同,则不添加新 toast,而是 × 2 → × 3 → × 4 → ... → × N
if (lastToast && lastToast.text === text && lastToast.type === type) {
lastToast.count++;
// 重置计时器
if (lastToast.timerId) {
clearTimeout(lastToast.timerId);
}
lastToast.timerId = setTimeout(() => {
removeToast(lastToast.id);
}, duration);
} else {
// 是新 toast 则加入队列
const id = idCounter++;
const newToast = {
id,
text,
type,
duration,
count: 1,
timerId: null,
};
// 设置自动消失计时器
newToast.timerId = setTimeout(() => {
removeToast(id);
}, duration);
toasts.value.push(newToast);
if (type === "success") {
playSound("toast", "success");
}
if (type === "error") {
playSound("toast", "error");
}
}
}
function removeToast(id) {
const index = toasts.value.findIndex((toast) => toast.id === id);
if (index !== -1) {
// 在移除 toast 前清除可能附着在其上的计时器
if (toasts.value[index].timerId) {
clearTimeout(toasts.value[index].timerId);
}
toasts.value.splice(index, 1);
}
}
ToastDrawer
组件只需要拿着 toast
store 提供的 toasts
数组 v-for
ToastItem
即可,顺便用 <transition-group>
加入入列和出列动画,没什么技术含量。不讲了。
Notification 会从屏幕右上角推出来,任务系统在任务完成时拿着后端返回的任务对象调用 Notification 提醒用户,而前文所说的“一键路由”按钮也在 NotificationItem
里。和 Toast 的实现几乎完全一致,我也不说了。
总结
经过了“二、数据通讯(上)”和“三、数据通讯(下)”,诤略参谋的数据通讯系统框架终于建成——
我也不能光顾着高兴。这篇博客给未来留下了一些靶子:
- 学习 SSE 相关知识,用 SSE 取代轮询。
- 涉及 LLM 的第一类 API 和异步请求成功回调只能在未来根据具体需求编写,那时我们可能会发现一些当下没发现的可复用代码或逻辑。
- 诤略参谋是我第一个使用 Pinia(或者说 store 概念)的项目。我引入 store 的动机只是存储和利用“二、数据通讯(上)”中以自动登录为代表的用户配置,可在这篇博客中,任务系统用了
task
store、弹窗系统用了toast
、notification
和modal
三个 store,我们讨论一键路由时也提到未来要用plan
等 store……store 在本项目里的含金量在狂飙,哪怕截至目前我并未专门思考如何利用 store。所以未来我会关注“状态集中管理”的相关思想和应用。噢,三件套的fieldStates
“注册表”也是一种“状态集中管理”……
无论如何,诤略参谋的数据通讯系统设计到此结束。