在前面 Spring AI Chat 简单示例 中我们只调用了一次请求,返回了一个结果,我们见过的各种 chat 都是支持连续对话的,AI 需要记住我们的上下文才能让对话连贯起来,通过 API 调用的时候每次对话都是一次无状态的独立请求,想要实现连续对话就需要我们自己记住对话的历史,在每次调用 API 的时候将对话历史传递给 API。
本文就简单实现连续对话,并且引申一些相关的扩折或者优化。
基本功能 首先要知道 Spring AI 中交互的消息对象,在 Prompt
中存在 public Prompt(List<Message> messages)
构造方法,可以传递多个消息,Message 中存在消息类型 MessageType,包含下面几种类型:
1 2 3 4 5 public enum MessageType { USER("user" ), ASSISTANT("assistant" ), SYSTEM("system" ), FUNCTION("function" );
这四种类型分别对应的 Message 实现,如 UserMessage
,SystemMessage
。
在 API 调用返回的 ChatResponse
包含的 Generation
中返回的是 AssistantMessage
消息,因此只需要通过 List<message>
按顺序记录消息历史就可以实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 var openAiApi = ...ChatClient chatClient = new OpenAiChatClient (openAiApi, OpenAiChatOptions.builder() .withModel("gpt-3.5-turbo" ) .withTemperature(0.4F ).build()); List<Message> messages = new ArrayList <>(); messages.add(new SystemMessage ("你是一个聊天机器人,所有回复请使用中文。" ));Scanner scanner = new Scanner (System.in);while (true ) { System.out.print(">>>>> " ); String message = scanner.nextLine(); if (message.equals("exit" )) { break ; } messages.add(new UserMessage (message)); ChatResponse response = chatClient.call(new Prompt (messages)); messages.add(response.getResult().getOutput()); System.out.println("<<<<< " + response.getResult().getOutput().getContent()); }
最近在看《大道朝天》,所以问几个相关的问题进行测试:
1 2 3 4 5 6 7 >>>>> 大道朝天的作者是谁? <<<<< 大道朝天的作者是明代著名文学家冯梦龙。 >>>>> 你知道猫腻是谁吗? <<<<< 猫腻是中国知名网络小说作家,代表作品有《择天记》、《将夜》等。 >>>>> 大道朝天的作者是谁? <<<<< 大道朝天的作者是明代著名文学家冯梦龙。 >>>>> exit
可以看到 AI 的回复是错误的,尤其是有历史后,回复会变得更固定。当我去掉对话历史时,回复的内容会有所不同:
1 2 3 4 5 6 7 8 > >>>> 大道朝天的作者是谁? <<<<< 大道朝天的作者是唐代诗人李白。> >>>> 大道朝天的作者是谁? <<<<< 大道朝天的作者是唐代诗人杜甫。> >>>> 大道朝天的作者是谁? <<<<< 大道朝天的作者是庄子。> >>>> 大道朝天的作者是谁? <<<<< 大道朝天的作者是李白。
接下来是几个基于对话的扩展。
预设上下文 当前很火的 RAG 技术实际上就是将已有知识向量化之后通过向量查询,将查询的结果作为上下文提供给 AI,在此基础上进行回答,上面的问题中,我们可以增加一个上下文,让 AI 回答的更准确,修改前面示例增加上下文:
1 2 3 4 5 6 List<Message> messages = new ArrayList <>(); messages.add(new SystemMessage ("你是一个聊天机器人,所有回复请使用中文。" )); messages.add(new SystemMessage ("猫腻 ,本名晓峰,1977年出生于湖北省宜昌市夷陵区,网络作家,橙瓜见证·网络文学20年十大玄幻作家,百强大神作家,百位行业人物。\n" + "猫腻曾就读于四川大学(未毕业),后从事网络文学创作,现为阅文集团白金作家。" + "代表作《朱雀记》《庆余年》《间客》《将夜》《择天记》《大道朝天》、《映秀十年事》等,其中《朱雀记》于2007年获得新浪第四届原创大赛·奇幻武侠奖一等奖;" + "《间客》于2013年获得首届“西湖·类型文学双年奖”银奖 ,于2018年入选“中国网络文学20年20部作品”,位列榜首 ;《将夜》于2015年获得首届网络文学双年奖金奖 。" ));
把百度的一段内容放到了上下文,再重新进行上面的对话:
1 2 3 4 5 6 7 8 9 >>>>> 大道朝天的作者是谁? <<<<< 《大道朝天》的作者是网络作家猫腻。 >>>>> 你知道猫腻是谁吗? <<<<< 猫腻是网络作家的笔名,本名是晓峰,是一位知名的网络文学作家,代表作品包括《朱雀记》、《庆余年》、《间客》、《将夜》、《择天记》、《大道朝天》等。 >>>>> 猫腻获得过哪些奖? <<<<< 猫腻曾获得过多个奖项,其中包括:1. 《朱雀记》于2007年获得新浪第四届原创大赛·奇幻武侠奖一等奖;2. 《间客》于2013年获得首届“西湖·类型文学双年奖”银奖;3. 《将夜》于2015年获得首届网络文学双年奖金奖。
有了上下文后 AI 的回答更准确了。
减少 token 数 AI 的调用价钱和 token 数相关,所以如果支持无限长度的对话会使得成本快速升高,所以一般的对话会限制连续对话的次数,或者通过 AI 将对话总结后减少需要传递的内容。
如果想减少次数,最简单的就是判断对话数,然后减少前面的内容,在原始代码中修改如下:
1 2 3 4 5 messages.add(new UserMessage (message));if (messages.size() > 10 ) { messages.remove(0 ); }
如果通过总结的方式,还可以额外调用一次 AI,然后去掉前面几次的 user 和 assistant 内容,将总结的内容追加到最后,再加上本次的用户消息进行发送。
支持多人对话 示例中只能单线程的一个人进行对话,如果通过 http 提供服务,想要支持多人对话,最简单的方式就是给每个对话分配一个对话id,然后将对话id和对应的对话历史联系起来,例如使用 Map
:
1 Map<String, List<Message>> chatMessage = new ConcurrentHashMap <>();
对话时都带着对话id即可将多人的对话区分开。
连续对话的实现比较简单,我们能额外做的就是在 API 调用时如何获取并提供预设信息,如何处理额外的 API 调用实现一些特殊需求,在消息最终展示给用户时需要做的各种处理。