前言
OpenAI引领风潮的大语言模型(以下称为LLM)出世,给行业带来无比震撼,公众号、朋友圈、新闻与短视频App里,无不充斥着哪一家AI公司又放出效能更强大的模型之类的信息,文字生成、图像生成、音频生成、视频生成等的效果有多么令人惊叹。但LLM的行业应用、业务场景、怎么落地、如何将LLM技术融入系统之中,却一直处在被高度讨论,但多数人又摸不着门道的尴尬情况。
从开发者的角度来看,LLM必须能跟外部系统交互, 比如能调用外部API并传入参数,取得外部API返回数据后,LLM再进行下一步推理,从而生成出正确的结果,避免产生幻觉或根本错误的答案。OpenAI在2023年中推出函数呼叫(以下称为Function calling)功能,提供开发者能将LLM与外部API调用融合的能力,直接导致智能体(以下称为Agent)应用更为可能。Function calling目前也逐步被其它AI公司(非全部)所采用,如本文实验中采用的智谱GLM-4大模型即支持Function calling。
对AI应用开发者来说,Python是首选语言,[LangChain]几乎是首选框架。LangChain提供两种程序语言的实作版本,[Python版]与[Javascript版],Python版是当然的主力,也最成熟。然而对于Java或JVM生态系的开发者而言,[LangChain4J]框架绝无法错过。LangChain4J与LangChain的作者不同,也不属于同一个开源家族,但较晚出现的LangChain4J,极大的吸收LangChain的设计精神,并汲取[Haystack]与[LlamaIndex]部分设计,尽可能让LangChain4J能赶上LangChain。LangChain4J进一步发展出更具Java味的特色,如与[Spring]、[Quarkus]的集成良好,且高阶API设计取向亦不同于LangChian。虽然LangChain4J的版本迭代速度不如LangChain,但核心功能已具备生产能力。
在本文范例,我们使用LangChain4J的高阶API来创建一个简单的Agent,用户通过Agent与GLM-4交互,并通过GLM-4的Function calling功能来调用工具(以下称为Tool,即外部API),模拟一个非常简单的本地商店发现与下单场景。范例的目的除了学习LangChain4J的Agent与Tool之基本开发外,还包括部分的协议追踪分析。我们使用Kotlin作为开发语言,以Quarkus实现后端。
我想吃点东西
范例中的Tool功能很简单:
- 用户想吃点东西,根据位置,查询附近的蛋糕店、奶茶店、咖啡店等,并得到每家店的美味清单。
- 用户选择想吃的美食,如拿破仑蛋糕,再次查询附近卖拿破仑蛋糕的店。
- 用户下单。
- 用户来个开盲盒,找最近的一家店直接下单!
Tool代码中不会真的实现定位、查询与下单的业务功能,这部分与Function calling的逻辑无关,我们只返回示例数据,但不影响Functional calling的示意。
首先准备好一个Quarkus Service Bean:
package dev.example
import dev.langchain4j.memory.chat.ChatMemoryProvider
import dev.langchain4j.memory.chat.MessageWindowChatMemory
import dev.langchain4j.model.qianfan.QianfanChatModel
import dev.langchain4j.service.AiServices
import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore
import io.quarkus.runtime.Startup
import jakarta.enterprise.context.ApplicationScoped
import org.jboss.logging.Logger
@ApplicationScoped
class AiService {
companion object {
private val LOGGER: Logger = Logger.getLogger("AiService")
}
private lateinit var chatMemoryProvider: ChatMemoryProvider
private lateinit var assistant: Assistant
@Startup
fun init() {
val zhipuApiKey = System.getenv("ZHIPU_API_KEY")
chatMemoryProvider = ChatMemoryProvider { memoryId: Any? ->
MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(99)
.chatMemoryStore(InMemoryChatMemoryStore())
.build()
}
assistant = AiServices.builder(Assistant::class.java)
.chatLanguageModel(
ZhipuAiChatModel.builder()
.apiKey(zhipuApiKey)
.model("glm-4")
.build()
)
.chatMemoryProvider(chatMemoryProvider)
.tools(Tools.FindAndOrderSomething())
.build()
LOGGER.info("LangChain4J AiServices are initialized, we are using GLM-4 model.")
}
fun chat(message: String): String {
val answer = assistant.sendMessage("my_id", message)
LOGGER.info("Answer from LLM: $answer")
return answer
}
}
熟悉Spring或Quarkus的开发者对这段代码应该不陌生,AiService
是一个Service Bean,包含init()
与chat()
两个function。
init()
:
init()
标注入了@Startup
Annotation,表示init()
会在Quarkus实例启动时被执行。init()
内通过LangChain4J提供的AiServices
创建一个代表LLM的交互入口,请注意,AiServices
是LangChain4J提供的类,请勿与我们编写的AiService
混淆(差了一个s)。在创建AiServices
实例时,需指定要使用的LLM所对应的实例,由于实验的对象是智谱大模型,而LangChain4J已经支持智谱,因此这个实例是用ZhipuAiChatModel
创建。- 其次,需要准备一个
ChatMemoryProvider
实例,用途是纪录与LLM对话中的历史信息,LLM正是通过对话上下文,来生成更正确的推理内容。(本文第一次发布时使用百度ERNIE-Bot,根据千帆技术规定,对话信息必须是单数个,代码中设定为99,这个长度足够实验使用。如果设定为偶数个,比如100,当对话到达100个信息后,千帆会报错。后我们改为智谱,依旧使用99这个设定)。在多用户应用场景中,每个用户与LLM的对话应该相互独立,因此LangChain4J支持通过不同的MemoryId
来区分内部的ChatMemory
。 - 再来,通过
tools()
,传入已经实作好的Tool实例,范例中为FindAndOrderSomething
。Tool是Function calling里的重要实现,当LLM返回要调用外部API时,LangChain4J会通过Java反射机制来自动调用Tool实例内的function,并返回结果给LLM。下文将对FindAndOrderSomething
代码详细说明。 - 当
AiServices
构建完成后,得到一个Assistant
实例,顾名思义,即助理,我们要向LLM发送信息并得到返回,都通过这个实例。下文将对Assistant
代码进行说明。
chat()
:
- 这是
AiService
Service Bean开放出来的接口,熟悉Spring或Quarkus的开发者,应该能轻松理解。 - 当
AiService
在外部被依赖注入并调用chat()
时,信息则通过已经建立好的Assistant
实例(助理)之sendMessage()
,发送给LLM并得到返回信息,chat()
的命名由此而来。 sendMessage()
第一个参数是MemoryId
,范例中简单定为my_id
。在多用户应用中,可使用唯一用户识别变量,如用户ID。
Assistant
代码
package dev.example
import dev.langchain4j.service.MemoryId
import dev.langchain4j.service.SystemMessage
import dev.langchain4j.service.UserMessage
import dev.langchain4j.service.V
interface Assistant {
@SystemMessage(
"""
你是一个智能助理,一个能够帮助用户查询附近美食店、蛋糕店、奶茶店、冰品店、特色餐厅等,并能帮助用户下单的助手。
如果你不知道答案,并发现有函数可以调用以得到答案,请每次都调用函数并以函数的返回结果为正确答案。
调用函数时不需要向用户说明,直接调用。
""",
"""
You are an intelligent assistant, an assistant that can help users search for nearby gourmet shops, cake shops,
milk tea shops, ice cream shops, specialty restaurants, etc., and help users place orders.
If you don't know the answer and find a function you can call to get the answer, call the function everytime
and use the function's return result as the correct answer. When calling a function, there is no need
to explain it to the user, just call it directly.
"""
)
@UserMessage("我是用户:{{message}}")
fun sendMessage(
@MemoryId memoryId: String,
@V("message")
userMessage: String
): String
}
Assistant
的实作非常简单,是一个单纯的interface,只宣告一个向LLM发送信息的function,将之命名为sendMessage()
,即在AiService
的chat()
中被调用的sendMessage()
。- 我们使用
@SystemMessage
和@UserMessage
Annotation来标注userMessage
。@SystemMessage
表示你想让LLM成为什么样的助手、具备什么特定能力、能做什么事情,简言之,这是一段提示词(Prompt),使用过大模型的朋友,或有自定义智能体的经验,@SystemMessage
做的是类似的事。这里我们明确引导LLM是「一个能够帮助用户查询附近美食店、蛋糕店、奶茶店、冰品店、特色餐厅等,并能帮助用户下单的助手」。请注意@SystemMessage
括号内参数为任意数量,可以是单一字符串,或多个字符串。范例中第一个参数是中文提示词、第二个参数是英文提示词。根据经验,提供英文提示词将对LLM的正确推断有莫大的帮助。 @UserMessage
内的文字是每一次我们向LLM发送信息时,都会自动附加上的文字,因此可以把这段文字想像为模版,{{ message }}
部分会被userMessage
的值替代掉,当然,我们还必须引用@V
Annotation,@V
里的message
需与「模版」里的message
相同。比如每次用户输入「我在徐家汇」,则实际发送给LLM的是「我是用户:我在徐家汇」。- 在上文
AiService
代码中,LangChain4J的AiServices
内部通过Java反射机制与动态bytecode生成来创建一个Assistant
实例,因此即便它是一个interface,也可以被调用。
Tool
代码
package dev.example
import dev.langchain4j.agent.tool.P
import dev.langchain4j.agent.tool.Tool
import dev.langchain4j.agent.tool.ToolMemoryId
import org.jboss.logging.Logger
class Tools {
companion object {
private val LOGGER: Logger = Logger.getLogger("Tools")
}
internal class FindAndOrderSomething {
@Tool(
"想吃点什么得先找附近的店。查询附近的美食店、蛋糕店、奶茶店、咖啡店、特色餐厅...等。",
"""
If user wants something to eat, need to find a nearby store first.
Check nearby gourmet shops, cake shops, milk tea shops, coffee shops, special restaurants, etc.
"""
)
fun some_nice_stores_nearby(
@ToolMemoryId memoryId: String,
@P("当前地点, location") location: String
): String {
LOGGER.info("I am looking for something yammy around $location")
return ToolResults._1
}
@Tool(
"找出在用户当前地点附近,所有用户指定想吃的东西的店。",
"Find all stores near the user's current location that have the food the user specifies they want to eat."
)
fun something_delicious_nearby(
@ToolMemoryId memoryId: String,
@P("当前地点, location") location: String,
@P("想吃的东西, food product") yammy: String
): String {
LOGGER.info("I am in $location and I am interested in $yammy")
return ToolResults._2
}
@Tool(
"在一家指定的商店里下单,指定商品名称与数量。",
"Place an order in a designated store, specifying the food product name and quantity."
)
fun press_an_order(
@ToolMemoryId memoryId: String,
@P("店名, store") store: String,
@P("想吃的东西, food product") yammy: String,
@P("数量, quantity") quantity: Int
): String {
LOGGER.info("Press an order of $quantity $yammy in $store")
return ToolResults._3
}
}
}
Tool是使用LangChain4J开发Agent应用的重头戏,也是Function calling调用后,实际的业务逻辑代码所在:
- 每一块Function calling的实作,都只是朴素的function,但这里出现了两个新的Annotation,
@Tool
与@P
。 @Tool
接受一段话,作用与提示词相当,@Tool
里的文字能引导LLM能根据用户发来的上下文信息、以及Tool的文字标注,推论是否该调用Tool。根据我们的测试,这一段文字描述对Tool是否被成功调用,有决定性的关系。试想一下,我们一直在调整提示词以引导LLM产生更好的生成内容,道理相同。与@SystemMessage
相同,@Tool
的参数也是任意数量,范例中分别是中文与英文提示,目的同样是提升LLM推断并正确调用函数的能力。@P
是一句较短的叙述,用以描述对应的参数。由于@P
只支持单一参数,而非任意数量,这里我们用逗号隔开,同样提供中英文。- Tool的function名称与parameter名称随意,只要符合Java或Kotlin规范即可,LangChain4J会自动把function与parameter的名称与类型取出来,在对LLM发送请求信息时一并送出。
我们写的三个Tool function,返回值是模拟查询结果,格式为Json字符串。LangChain4J会自动判断Tool的返回,如果是String
类型,则直接返回;如果是其它类型,则自动转为Json格式返回;如果没有返回值,LangChain4J回传"Success"给LLM。
这三个Tool function的返回代码:
package dev.example
object ToolResults {
val _1 = """
{
"stores": [
{
"store": "中华小当家川菜店",
"distance": "1千米",
"yammy": [
"麻辣火锅",
"川式烧烤"
]
},
{
"store": "有腔调蛋糕店",
"distance": "1千米",
"yammy": [
"拿破仑蛋糕",
"榴梿千层蛋糕",
"芒果千层蛋糕"
]
},
{
"store": "100°C",
"distance": "1.5千米",
"yammy": [
"美式咖啡",
"拿铁咖啡",
"摩卡咖啡",
"拿破仑蛋糕"
]
},
{
"store": "狂喜奶茶店",
"distance": "2千米",
"yammy": [
"珍珠奶茶",
"玫瑰红茶",
"柠檬绿茶",
"拿破仑蛋糕"
]
}
]
}
""".trimIndent()
val _2 = """
{
"stores": [
{
"store": "有腔调蛋糕店",
"distance": "1千米",
"yammy": "拿破仑蛋糕"
},
{
"store": "100°C蛋糕店",
"distance": "1.5千米",
"yammy": "拿破仑蛋糕"
},
{
"store": "狂喜奶茶店",
"distance": "2千米",
"yammy": "拿破仑蛋糕"
}
]
}
""".trimIndent()
val _3 = """
{
"order_to_go": true
}
""".trimIndent()
}
截至目前,我们共实作了三块主要代码,AiService
、Assistant
、及Tools
,这已经构成完备的LangChain4J Agent。为了营造更逼真的测试环境,我们再写一个简单的Web服务(也是使用Quarkus的缘故),并写一个简易的前端以便测试。
Web服务代码
package dev.example
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
@Path("/llm")
class WebResource {
@Inject
lateinit var aiService: AiService
@Path("/hello")
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
@GET
fun hello(): String {
return "hello"
}
@Path("/chat")
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
@POST
fun chat(question: String): String {
val answer = aiService.chat(question)
return answer
}
}
这个服务非常简单,只有一个接收前端POST请求的接口,路径为/llm/chat
,接口内调用AiService
(一个Service Bean)来向LLM发送信息。
测试
第一轮对话:用户发送「你好,我是Stephen,很高兴和你对话!」,GLM-4返回:
你好,Stephen!很高兴和你对话,有什么我可以帮助你的吗?
第二轮对话:用户发送「你能帮助我做什么呢?」,GLM-4返回:
我可以帮助你查询附近的美食店、蛋糕店、奶茶店、冰品店、特色餐厅等,并且还能帮助你下单。
请问你需要查询附近的店铺吗?
可以看见,我们在@SystemMessage
写的提示词发生作用了,GLM-4说明了自己的能力。
第三轮对话:用户发送「我在徐家汇商圈,想吃点什么,附近有什么好店?」,GLM-4返回:
1. 中华小当家川菜店,距离你1千米,提供麻辣火锅和川式烧烤。
2. 有腔调蛋糕店,距离你1千米,提供拿破仑蛋糕、榴梿千层蛋糕和芒果千层蛋糕。
3. 100°C,距离你1.5千米,提供美式咖啡、拿铁咖啡、摩卡咖啡和拿破仑蛋糕。
4. 狂喜奶茶店,距离你2千米,提供珍珠奶茶、玫瑰红茶、柠檬绿茶和拿破仑蛋糕。
你想吃哪一类食物呢?
当我们看到这个返回,那么Agent Tools的技术算是实现了,比对前面Tools
里的some_nice_stores_nearby
function,可以发现GLM-4调用了这个函数,并利用函数的返回数据(参考ToolResults
的val _1
),以成为对话的输出依据。再查看后端打印的日志,可以清楚看到:
[Tools] (executor-thread-1) I am looking for something yammy around 徐家汇商圈
用户发送的信息明确指出地点、想吃东西、找店的意图,而some_nice_stores_nearby
也如期望被调用,返回的测试数据被完整的融入生成内容里。我们的Tool发生作用了。
第四轮对话:用户发送「我想吃拿破仑蛋糕,附近有哪些店,都帮我找出来。」,GLM-4返回:
好的,Stephen,根据你的要求,我找到了以下几家提供拿破仑蛋糕的店:
1. 有腔调蛋糕店,距离你1千米。
2. 100°C,距离你1.5千米。
3. 狂喜奶茶店,距离你2千米。
你想去哪家店购买呢?
根据生成内容,再比对后台日志,something_delicious_nearby
被成功调用,返回的测试数据也被完全融入生成内容。值得注意,something_delicious_nearby
需要两个参数,第一个参数是地点,而我们信息这一次没有指定地点,表示GLM-4在对话上下记住了我在徐家汇商圈,调用something_delicious_nearby
时正确的传入两个参数:徐家汇商圈、拿破仑蛋糕,且顺序正确。从后端日志中,可以更精准确认Tool调用成功:
[Tools] (executor-thread-1) I am in 徐家汇商圈 and I am interested in 拿破仑蛋糕
第五轮对话:用户发送「好的,帮我点有腔调蛋糕店的拿破仑蛋糕,2份。」,GLM-4返回:
好的,Stephen,我已经帮你下单有腔调蛋糕店的拿破仑蛋糕,2份。订单已经准备好了,你可以去取了。
需要我帮你做什么吗?
根据生成内容,再比对后台日志,press_an_order
被成功调用,参数正确,顺序正确。后端日志是:
[Tools] (executor-thread-1) Press an order of 2 拿破仑蛋糕 in 有腔调蛋糕店
第六轮对话:用户发送「再帮我追加1份。」,GLM-4返回:
好的,Stephen,我已经帮你追加了一份有腔调蛋糕店的拿破仑蛋糕。这份也准备好了,你可以一起去取。
还需要我帮你做什么吗?
根据生成内容,再比对后台日志,press_an_order
被成功调用,参数正确,顺序正确。我们没有明确指定有腔调蛋糕店与拿破仑蛋糕,GLM-4根据对话上下文能正确识别意图。
第七轮对话:开盲盒,用户发送「我在徐家汇,我想吃拿破仑蛋糕,直接帮我找最近的商店,下单两份。」,GLM-4返回:
最近的商店是有腔调蛋糕店,已经帮你下单了两份拿破仑蛋糕。
请注意,这一轮对话即是我们希望的在一次提示中,LLM能根据定义的Tools与提示词语意,由LLM推论判断是否要调用多个function来完成用户的意图。用户在一句话中给出了地点、想吃拿破仑蛋糕,找最近的店直接下单。根据我们在Tools
的设计,需要组合调用something_delicious_nearby
与press_an_order
来共同完成,GLM-4能正确理解用户意图并按正确顺序达成目的。查看后端日志,能发现两个Tool被按次序先后调用,参数正确,顺序正确。
[Tools] (executor-thread-1) I am in 徐家汇 and I am interested in 拿破仑蛋糕
[Tools] (executor-thread-1) Press an order of 2 拿破仑蛋糕 in 有腔调蛋糕店
至此七轮对话结束,我们不单验证了LLM的Function calling功能,还探索了如何使用LangChain4J开发一个Agent,完成对应且必要的Tool组合调用。
提示词的重要性
曾经调用LLM的API并开发过应用的朋友,相信对如何写提示词都有一套自己的经验。LLM很聪明,因此让我们惊艳;但LLM也很笨,需要我们把提示词写的完整,正向提示与反向提示,中文与英文同时提供,英文提示词的重要性甚至高于中文,这应该是LLM预训练时英语的语料丰富度远高于中文语料。
当我们在测试GLM-4时,偶发性的发现GLM-4会返回「它要调用某某某function」,很直白的告诉我们他要做什么。从我们这个应用测试的需求来看,我们希望GLM-4直接做,直接给答案,不需要说明它背后调用了哪些function,达到逻辑隐藏的目的。我们在@SystemMessage
里的提示词里明确提出要求,请别以为这是些机器看不懂得废话,这是必要的。
提示词工程师这个词的出现绝对不是偶然,许多LLM或文生图的应用中,工程师花在提示词调优的时间,不亚于写代码并除错的时间。
一些技术解释
- 前文不断提到LLM调用Function/Tool,这其实不完全正确,前文采用这种说法只是方便叙述。正确的描述,应该是LLM发现需要调用Function,会在返回的Body里指示,而应用端需要根据指示,自行去调用Tool。我们使用LangChain4J,因此是LangChain4j帮我们分析了LLM回传的Body,自动识别要调用的Tool,完成调用并返回结果给LLM。如果用Proxyman分析HTTP协议,可以发现只要涉及到Tool调用,则会出现多次HTTP POST,第一次是用户发送信息,LLM返回并提示要调用Tool;第二次是LangChain4J调用Tool并将数据发送给LLM,LLM根据Tool数据生成最终答案。对外部应用乃至对开发者,都只看到第二次协议交互的最终结果,LangChain4J帮我们做掉了复杂的部分。
- 范例用的是智谱GLM-4,其它大模型支持吗?当我们使用LangChain4J时,这涉及到三个层次:一、模型本身原生API支不支持原生的Function calling,这需要直接查看各模型的官方文档。二、需要有接入模型的Java SDK或通过HTTP client实作了模型的原生HTTP API。LangChain4J自身并没有实作国内外各模型的Java SDK,而是直接采用各模型公司的官方Java SDK,或者由开源爱好者贡献给社群。三、即使前两项成立,还需要LangChain4J根据SDK实作各自的模型接口。范例中使用智谱GLM-4,是因为上述三样都成立。
最后,希望本文能帮助到对LLM与LangChain4J有兴趣的开发者,欢迎指正。
大模型&AI产品经理如何学习
求大家的点赞和收藏,我花2万买的大模型学习资料免费共享给你们,来看看有哪些东西。
1.学习路线图
第一阶段: 从大模型系统设计入手,讲解大模型的主要方法;
第二阶段: 在通过大模型提示词工程从Prompts角度入手更好发挥模型的作用;
第三阶段: 大模型平台应用开发借助阿里云PAI平台构建电商领域虚拟试衣系统;
第四阶段: 大模型知识库应用开发以LangChain框架为例,构建物流行业咨询智能问答系统;
第五阶段: 大模型微调开发借助以大健康、新零售、新媒体领域构建适合当前领域大模型;
第六阶段: 以SD多模态大模型为主,搭建了文生图小程序案例;
第七阶段: 以大模型平台应用与开发为主,通过星火大模型,文心大模型等成熟大模型构建大模型行业应用。
2.视频教程
网上虽然也有很多的学习资源,但基本上都残缺不全的,这是我自己整理的大模型视频教程,上面路线图的每一个知识点,我都有配套的视频讲解。
(都打包成一块的了,不能一一展开,总共300多集)
因篇幅有限,仅展示部分资料,需要点击下方图片前往获取
3.技术文档和电子书
这里主要整理了大模型相关PDF书籍、行业报告、文档,有几百本,都是目前行业最新的。
4.LLM面试题和面经合集
这里主要整理了行业目前最新的大模型面试题和各种大厂offer面经合集。
👉学会后的收获:👈
• 基于大模型全栈工程实现(前端、后端、产品经理、设计、数据分析等),通过这门课可获得不同能力;
• 能够利用大模型解决相关实际项目需求: 大数据时代,越来越多的企业和机构需要处理海量数据,利用大模型技术可以更好地处理这些数据,提高数据分析和决策的准确性。因此,掌握大模型应用开发技能,可以让程序员更好地应对实际项目需求;
• 基于大模型和企业数据AI应用开发,实现大模型理论、掌握GPU算力、硬件、LangChain开发框架和项目实战技能, 学会Fine-tuning垂直训练大模型(数据准备、数据蒸馏、大模型部署)一站式掌握;
• 能够完成时下热门大模型垂直领域模型训练能力,提高程序员的编码能力: 大模型应用开发需要掌握机器学习算法、深度学习框架等技术,这些技术的掌握可以提高程序员的编码能力和分析能力,让程序员更加熟练地编写高质量的代码。
1.AI大模型学习路线图
2.100套AI大模型商业化落地方案
3.100集大模型视频教程
4.200本大模型PDF书籍
5.LLM面试题合集
6.AI产品经理资源合集
👉获取方式:
😝有需要的小伙伴,可以保存图片到wx扫描二v码免费领取【保证100%免费】🆓