在上一篇文章 langchaingo用法详解及源码解析(一)中,我已详细介绍了 langchaingo 的整体设计理念、各模块的功能划分,以及 llms
、prompts
和 memory
包的源码实现。本篇将重点介绍 tools
、chains
、agents
以及 callbacks
包的源码实现。如果你对上述内容尚不熟悉,可以先阅读上一篇文章作为背景知识补充。
tools包核心用法及源码解析:
tools
包用于解决大模型调用外部工具的问题。大语言模型本质上是一个基于概率统计的语言模型,它在接收到提示词后,会根据训练数据中词语之间的共现关系,预测最有可能出现的下一个词。这一过程是静态的、封闭的,本质上是一种"猜测":模型通过拟合训练语料中相似问题的高频回答,来生成当前的响应。因此,这种机制天然不具备实时访问外部信息或严谨推理计算的能力。
比如,大模型无法知道"当前时间"或"今天的天气",因为这些信息不可能包含在其预训练数据中;同样,面对复杂的数学表达式时,它通常也无法准确计算出结果,因为模型不会真正调用底层计算引擎,而是凭语言模式猜测可能的数值。简单的表达式可能猜对,但复杂的推理或计算常常出错。
为了解决这一类"知识闭环"问题,langchaingo 提供了 tools
包来封装外部能力。MCP 等协议在实际开发中通常也被封装为实现了 Tool
接口的对象,由大模型通过调用工具来间接访问外部世界。以下是 Tool
接口的定义:
go
type Tool interface {
Name() string
Description() string
Call(ctx context.Context, input string) (string, error)
}
-
Name()
返回工具的唯一名称。大模型在决策过程中会输出一个工具名,框架根据名称触发相应工具的调用。 -
Description()
返回工具的用途描述,这一描述会被融入提示词,使大模型理解工具的功能和使用场景。 -
Call()
实现实际的工具逻辑。注意input
和返回值都是字符串类型,这是因为大模型的输入输出本质上都是文本。若希望模型传入 JSON 参数,需在提示词中说明格式要求及字段含义。
理解接口不难,但若仅看定义较为抽象,我们不妨从两个tools包内置的典型实现来直观了解 Tool
的用途和用法。
第一个例子是一个用于计算数学表达式的工具:
go
type Calculator struct {
CallbacksHandler callbacks.Handler
}
var _ Tool = Calculator{}
// Description 工具的描述信息,表明此工具可以返回一段数学表达式的计算结果,
// 同时指定入参必须是一段正常的数学表达式
func (c Calculator) Description() string {
return `Useful for getting the result of a math expression.
The input to this tool should be a valid mathematical expression that could be executed by a starlark evaluator.`
}
// Name 返回工具的名称为 calculator
func (c Calculator) Name() string {
return "calculator"
}
// Call 中实际实现了计算数学表达式的逻辑
func (c Calculator) Call(ctx context.Context, input string) (string, error) {
if c.CallbacksHandler != nil {
c.CallbacksHandler.HandleToolStart(ctx, input)
}
// 调用 starlark.Eval 计算表达式的值
v, err := starlark.Eval(&starlark.Thread{Name: "main"}, "input", input, math.Module.Members)
if err != nil {
return fmt.Sprintf("error from evaluator: %s", err.Error()), nil //nolint:nilerr
}
result := v.String()
if c.CallbacksHandler != nil {
c.CallbacksHandler.HandleToolEnd(ctx, result)
}
return result, nil
}
这个工具通过 starlark.Eval
实现对数学表达式的求值,并通过c.CallbacksHandler.HandleToolEnd(ctx, result)
回调上报了工具执行结束事件,结构清晰,逻辑简单。
另一个常见的工具是联网搜索工具,比如 DuckDuckGo 搜索:
go
type Tool struct {
CallbacksHandler callbacks.Handler
client *internal.Client
}
var _ tools.Tool = Tool{}
func New(maxResults int, userAgent string) (*Tool, error) {
return &Tool{
client: internal.New(maxResults, userAgent),
}, nil
}
// Name 返回工具名称为 DuckDuckGo Search.
func (t Tool) Name() string {
return "DuckDuckGo Search"
}
// Description 返回工具描述,表明此工具可进行联网搜索,同时声明入参为要进行搜素的字符串
func (t Tool) Description() string {
return `
"A wrapper around DuckDuckGo Search."
"Free search alternative to google and serpapi."
"Input should be a search query."`
}
// Call 实际执行搜索逻辑
func (t Tool) Call(ctx context.Context, input string) (string, error) {
if t.CallbacksHandler != nil {
t.CallbacksHandler.HandleToolStart(ctx, input)
}
// 调用 client进行搜索,这个client实现的逻辑也很简单,就是使用 http.Client 访问一个url
// queryURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
// 通过此url获取搜索的结果
result, err := t.client.Search(ctx, input)
if err != nil {
if errors.Is(err, internal.ErrNoGoodResult) {
return "No good DuckDuckGo Search Results was found", nil
}
if t.CallbacksHandler != nil {
t.CallbacksHandler.HandleToolError(ctx, err)
}
return "", err
}
if t.CallbacksHandler != nil {
t.CallbacksHandler.HandleToolEnd(ctx, result)
}
return result, nil
}
该工具封装了对 DuckDuckGo 搜索接口的访问,支持联网查询实时数据,用于弥补大模型知识时效性不足的问题。
综上所述,实现一个 Tool
并不复杂,只需实现 Name
、Description
和 Call
三个方法。关键在于清晰描述工具用途和参数规范,并在 Call
方法中处理好输入解析、执行逻辑与结果返回。通过 tools
机制,langchaingo 实现了将大模型从封闭预测系统拓展为可调用外部能力的智能体基础模块。
chains包核心用法及源码解析:
在前文中,我们已经详细介绍了 llms
、prompts
、memory
和 tools
包,构建一个大模型应用,基本上就是围绕这几个模块展开的。然而读到这里,可能仍然存在一个疑惑:虽然知道这些模块分别是做什么的,但它们彼此独立,缺乏协同机制,也不容易看出一个大模型应用到底是如何"运行起来"的。这正是 chains
包所要解决的核心问题 ------ 它是 langchaingo 框架中用于组织和编排各能力模块,形成"可执行流程"的关键机制。
在实际开发中,我们很少直接调用 llms
或 prompts
的方法,而是通过统一的 Chain
接口来组织和执行大模型相关的业务逻辑。可以认为,Chain
是 langchaingo 中的最小可执行单元。当我们想实现一段大模型应用逻辑时,往往会先将其封装为一个 Chain
对象,然后通过 chains.Run
或 chains.Call
来运行。
先来看 Chain
接口的定义:
go
type Chain interface {
Call(ctx context.Context, inputs map[string]any, options ...ChainCallOption) (map[string]any, error)
GetMemory() schema.Memory
GetInputKeys() []string
GetOutputKeys() []string
}
Call
是最核心的方法,表示该 Chain
对象实际的执行逻辑。其输入输出均为 map[string]any
,这使得所有 Chain
都可以灵活拼接。
GetInputKeys
/ GetOutputKeys
显式声明输入输出字段,类似函数签名。
GetMemory
返回挂载的 Memory 对象,用于管理对话上下文或状态。
定义好 Chain
之后,需要通过 chains.Call
、chains.Run
或 chains.Predict
来执行一个 Chain
,不过这些函数最终都是调用的 chains.Call
:
go
// Call 执行一个 Chain 对象
func Call(ctx context.Context, c Chain, inputValues map[string]any, options ...ChainCallOption) (map[string]any, error) { // nolint: lll
fullValues := make(map[string]any, 0)
// 此处为用户输入的数据
for key, value := range inputValues {
fullValues[key] = value
}
// 取出Chain关联的记忆中包含的数据
newValues, err := c.GetMemory().LoadMemoryVariables(ctx, inputValues)
if err != nil {
return nil, err
}
// 和用户输入的数据合并到一起
for key, value := range newValues {
fullValues[key] = value
}
callbacksHandler := getChainCallbackHandler(c)
if callbacksHandler != nil {
callbacksHandler.HandleChainStart(ctx, inputValues)
}
// 将全部数据交给 Chain 对象执行,此处会调用 Chain 对象的 Call方法
outputValues, err := callChain(ctx, c, fullValues, options...)
if err != nil {
if callbacksHandler != nil {
callbacksHandler.HandleChainError(ctx, err)
}
return outputValues, err
}
if callbacksHandler != nil {
callbacksHandler.HandleChainEnd(ctx, outputValues)
}
// 将输出的数据保存至记忆之中
if err = c.GetMemory().SaveContext(ctx, inputValues, outputValues); err != nil {
return outputValues, err
}
return outputValues, nil
}
Chain
的接口设计看似简单,实则体现了 langchain 在框架设计方面的深厚功力,
极简但高扩展性: 开发者只需实现一个 Call
方法即可完成业务逻辑的封装。无需理解复杂的数据流系统,也不用学习笨重的配置语法。简洁的接口设计极大降低了学习门槛,同时保留了灵活的拓展空间。
强组合性与模块复用能力: 所有 Chain
的输入输出格式统一(都是 map),并有明确的输入/输出字段声明,因此可以轻松进行组合。比如可以将多个 Chain
串联起来,形成流水线式推理流程,或根据条件分支执行不同 Chain
,甚至递归嵌套 Chain
实现树状结构。就像 web 框架中的中间件一样,开发者可以积木式地构建复杂应用。
状态感知能力: 每个 Chain
都可以挂载 Memory
,实现带状态的对话、多轮记忆、用户上下文管理等。并且这些 Memory
都可以通过 GetMemory
方法直接拿到,让 Chain
对象之间可以彼此共享上下文信息。
足够抽象,保留实现自由: Chain
本身并不限制其内部实现。你可以在 Call
方法内部封装 LLM 调用,也可以再调其他 Chain
、调用工具,甚至写 API 请求逻辑。这种"只定义执行入口和数据结构"的抽象方式,为开发者提供了最大程度的自由,且不会让人感到"框架反客为主"。
总体而言,Chain
定义的方法并没有规定各种细节,就是定义了一段大模型业务逻辑的执行入口,定义了参数及返回值,但我觉得这就是最合理的,大模型应用开发本身就是用自然语言进行交互,根本没有什么固定的步骤可言,抽象出一个统一的执行入口,再提供类似于 llms
、prompts
、memory
、tools
等工具包,然后由开发者自行去实现 Chain
接口的 Call
方法,也可以直接复用其他人的 Chain
对象,我觉得这是一种恰到好处的设计。
接下来我们看一下 chains
包内置的一些 Chain
接口的实现,更加直观地认识一下 Chain
对象的作用。
LLMChain:对大模型调用做一个基础封装
示例代码如下:
go
func main() {
llm, err := ollama.New(
ollama.WithModel("llama3.2"),
)
if err != nil {
log.Fatal(err)
}
prompt := prompts.NewPromptTemplate("你好,你是谁?我是 {{.user}}", []string{"user"})
chain := chains.NewLLMChain(llm, prompt)
input := map[string]any{"user": "hayson"}
output, err := chains.Call(context.Background(), chain, input)
if err != nil {
log.Fatal(err)
}
fmt.Println(output[chain.GetOutputKeys()[0]])
}
// output:
// 你好,hayson!我是你的AI助手,名叫Luna。欢迎来到我们的对话中!
// 我可以帮助你回答问题、提供信息、或只是聊天。怎么样呢?有什么需要帮助的吗?
这段代码做的事情很简单,就是问了大模型一个 "你是谁?" 的问题,也没有任何历史消息记录的逻辑,基本就相当于直接使用 llms.GenerateFromSinglePrompt
方法向大模型发送一段问题并得到回复,只是提示词是通过提示词模板渲染得到的,而不是直接传输一个字符串。故 LLMChain
就是对大模型调用做了一层很浅的封装,将大模型的调用包装为了统一的 Chain
接口的模式,以下为源码:
go
// NewLLMChain 通过一个模型对象以及一个提示词模板对象创建 LLMChain
func NewLLMChain(llm llms.Model, prompt prompts.FormatPrompter, opts ...ChainCallOption) *LLMChain {
opt := &chainCallOption{}
for _, o := range opts {
o(opt)
}
chain := &LLMChain{
Prompt: prompt,
LLM: llm,
OutputParser: outputparser.NewSimple(),
// 此处使用了 simple memory,就相当于不设置 memory,此对象实现的关于memory的方法均为空
Memory: memory.NewSimple(),
OutputKey: _llmChainDefaultOutputKey,
CallbacksHandler: opt.CallbackHandler,
}
return chain
}
// Call LLMChain 实现的 Call 方法
func (c LLMChain) Call(ctx context.Context, values map[string]any, options ...ChainCallOption) (map[string]any, error) {
// 渲染提示词模板,得到提示词字符串
promptValue, err := c.Prompt.FormatPrompt(values)
if err != nil {
return nil, err
}
// 将提示词发送给大模型并得到返回值,这里的返回值也是一个字符串
result, err := llms.GenerateFromSinglePrompt(ctx, c.LLM, promptValue.String(), getLLMCallOptions(options...)...)
if err != nil {
return nil, err
}
// 对大模型的返回值进行处理,此处的OutputParser内部的实现逻辑就是去掉了返回值字符串左右两侧的空格
finalOutput, err := c.OutputParser.ParseWithPrompt(result, promptValue)
if err != nil {
return nil, err
}
// 按照 map[string]any 返回输出值
return map[string]any{c.OutputKey: finalOutput}, nil
}
// GetMemory 返回自己使用的 memory 对象,这里返回的 simple memory 相当于没有记忆能力
func (c LLMChain) GetMemory() schema.Memory {
return c.Memory
}
func (c LLMChain) GetCallbackHandler() callbacks.Handler { //nolint:ireturn
return c.CallbacksHandler
}
// GetInputKeys 返回输入参数的键名,因为用的是提示词模板进行渲染,故这里返回的就是提示词模板中的参数列表
func (c LLMChain) GetInputKeys() []string {
return append([]string{}, c.Prompt.GetInputVariables()...)
}
// GetOutputKeys 返回输出参数的键名
func (c LLMChain) GetOutputKeys() []string {
return []string{c.OutputKey}
}
可以看到 LLMChain
核心就是使用 llms.GenerateFromSinglePrompt
直接生成一段大模型的回复,但前置会使用提示词模板进行提示词渲染,刚刚提到的 chains.Call
函数内部会将用户的输入和大模型的输出都写入memory,这里用的simple memory实际上无记忆能力,故 LLMChain
记不住上下文,只能进行一次性的问答,接下来看一个能记住上下文的Chain。
ConversationChain:进行能记住上下文的对话
示例代码如下:
go
func main() {
llm, err := ollama.New(
ollama.WithModel("llama3.2"),
)
if err != nil {
log.Fatal(err)
}
mem := memory.NewConversationBuffer()
chain := chains.NewConversation(llm, mem)
ctx := context.Background()
output, err := chains.Run(ctx, chain, "你好,我是hayson,热爱编程,尤其是golang语言")
if err != nil {
log.Fatal(err)
}
fmt.Println(output)
output, err = chains.Run(ctx, chain, "我的爱好是什么?")
if err != nil {
log.Fatal(err)
}
fmt.Println(output)
}
// output:
// 你好,hayson!我是Luna,一个旨在帮助开发者和 coding enthusiasts 的 AI。很高兴与你交流! Golang 是一个非常强大的语言,特别是在系统编程、网络编程和并行性方面。它的性能和安全性都非常出色。有哪些问题或需求需要我帮助吗?
// 你的爱好是什么? Ah,好!根据我们的对话,我知道你是热爱编程的,尤其是 Golang。那么,让我们谈谈你的爱好吧!
// 由于我们刚刚开始交谈,所以我还没有收集足够的信息来确定你的具体爱好。但是,如果你愿意分享一些关于你的兴趣或 hobby 的信息,我可以尝试帮助你了解它们与编程有关的关系。
可以看到此时和大模型进行对话时,大模型就能根据上下文信息进行回复了,此处我们使用的 memory 对象为 memory.ConversationBuffer
,我们之前已经介绍过这个对象了,默认会在内存中记录对话历史内容。 接下来可以看下 ConversationChain
的源码:
go
const _conversationTemplate = `
The following is a friendly conversation between a human and an AI.
The AI is talkative and provides lots of specific details from its context.
If the AI does not know the answer to a question, it truthfully says it does not know.
Current conversation:
{{.history}}
Human: {{.input}}
AI:`
func NewConversation(llm llms.Model, memory schema.Memory) LLMChain {
return LLMChain{
Prompt: prompts.NewPromptTemplate(
_conversationTemplate,
[]string{"history", "input"},
),
LLM: llm,
Memory: memory,
OutputParser: outputparser.NewSimple(),
OutputKey: _llmChainDefaultOutputKey,
}
}
可以看到其实 NewConversation
返回的就是 LLMChain
对象,只是memory属性是外部注入的,提示词则是预置好的,主要就是告诉大模型接下来要和人类进行对话,提示词包含两个变量,一个是历史对话记录{{.history}}
,一个是用户新的输入 {{.input}}
,LLMChain
中的Call
方法刚刚已经分析过了,就不再进行分析了,接下来主要是详细看下 chains.Call
是如何处理历史对话记录的:
go
func Call(ctx context.Context, c Chain, inputValues map[string]any, options ...ChainCallOption) (map[string]any, error) { // nolint: lll
fullValues := make(map[string]any, 0)
for key, value := range inputValues {
fullValues[key] = value
}
newValues, err := c.GetMemory().LoadMemoryVariables(ctx, inputValues)
if err != nil {
return nil, err
}
for key, value := range newValues {
fullValues[key] = value
}
callbacksHandler := getChainCallbackHandler(c)
if callbacksHandler != nil {
callbacksHandler.HandleChainStart(ctx, inputValues)
}
outputValues, err := callChain(ctx, c, fullValues, options...)
if err != nil {
if callbacksHandler != nil {
callbacksHandler.HandleChainError(ctx, err)
}
return outputValues, err
}
if callbacksHandler != nil {
callbacksHandler.HandleChainEnd(ctx, outputValues)
}
if err = c.GetMemory().SaveContext(ctx, inputValues, outputValues); err != nil {
return outputValues, err
}
return outputValues, nil
}
在 newValues, err := c.GetMemory().LoadMemoryVariables(ctx, inputValues)
中,会拿到memory中保存的数据,此方法在 ConversationBuffer
中实现如下:
go
func (m *ConversationBuffer) LoadMemoryVariables(
ctx context.Context, _ map[string]any,
) (map[string]any, error) {
messages, err := m.ChatHistory.Messages(ctx)
if err != nil {
return nil, err
}
if m.ReturnMessages {
return map[string]any{
m.MemoryKey: messages,
}, nil
}
bufferString, err := llms.GetBufferString(messages, m.HumanPrefix, m.AIPrefix)
if err != nil {
return nil, err
}
return map[string]any{
m.MemoryKey: bufferString,
}, nil
}
取出所有的对话记录,使用 m.MemoryKey
返回,而 m.MemoryKey
默认为 'history'
,代码如下:
go
func applyBufferOptions(opts ...ConversationBufferOption) *ConversationBuffer {
m := &ConversationBuffer{
ReturnMessages: false,
InputKey: "",
OutputKey: "",
HumanPrefix: "Human",
AIPrefix: "AI",
MemoryKey: "history", // 默认的 MemoryKey 为 history
}
for _, opt := range opts {
opt(m)
}
if m.ChatHistory == nil {
m.ChatHistory = NewChatMessageHistory()
}
return m
}
和预置的提示词中的参数 {{.history}}
是保持一致的,用于渲染提示词中的历史对话记录的内容。得到大模型的回复之后,使用 c.GetMemory().SaveContext(ctx, inputValues, outputValues)
,保存新一轮的对话内容。
我们可以打断点观察一下 LLMChain
的 Call
方法,在执行 promptValue, err := c.Prompt.FormatPrompt(values)
之后能得到提示词模板渲染后的结果。
第一次调用 chains.Run
得到的提示词为:
css
The following is a friendly conversation between a human and an AI.
The AI is talkative and provides lots of specific details from its context.
If the AI does not know the answer to a question, it truthfully says it does not know.
Current conversation:
Human: 你好,我是hayson,热爱编程,尤其是golang语言
AI:
第二次调用 chains.Run
得到的提示词为:
vbnet
The following is a friendly conversation between a human and an AI.
The AI is talkative and provides lots of specific details from its context.
If the AI does not know the answer to a question, it truthfully says it does not know.
Current conversation:
Human: 你好,我是hayson,热爱编程,尤其是golang语言
AI: 你好,hayson!我是Luna,一个旨在帮助开发者和 coding enthusiasts 的 AI。很高兴与你交流!
Golang 是一个非常强大的语言,特别是在系统编程、网络编程和并行性方面。它的性能和安全性都非常出色。有哪些问题或需求需要我帮助吗?
Human: 我的爱好是什么?
AI:
这里可以清晰地看到当我们第二次调用 chains.Run
时,发送给大模型的提示词就包含第一次对话的内容了。
LLMMathChain:通过对话计算数学表达式
LLMMathChain
在纯粹对话的基础上,增加了计算数学表达式的能力,以下为代码示例:
go
func main() {
llm, err := openai.New(
openai.WithBaseURL("url"),
openai.WithToken("key"),
openai.WithModel("qwen-plus"),
)
if err != nil {
log.Fatal(err)
}
chain := chains.NewLLMMathChain(llm)
ctx := context.Background()
output, err := chains.Run(ctx, chain, "988 * 563 * 89 计算结果是多少?")
if err != nil {
log.Fatal(err)
}
fmt.Println(output)
}
// output:
// 49505716
基于llm对象创建了一个 LLMMathChain
对象,之后问了一个计算数学表达式的问题,最终也得到了正确的计算结果。这里使用的大模型最好是相对参数较多的大模型,本地大模型可能会因为理解不好提示词没办法将数学表达式从用户输入中提取出来,导致这段程序执行失败。
接下来看下 LLMMathChain
的源码,
go
// NewLLMMathChain 基于 LLMChain 创建 LLMMathChain,提示词是预置好的,内容如下:
//
// Translate a math problem into a expression that can be evaluated as Starlark.
// Use the output of running this code to answer the question.
//
// ---
// Question: (Question with math problem.)
// ```starlark
// $(single line expression that solves the problem)
// ```
//
// ---
// Question: What is 37593 * 67?
// ```starlark
// 37593 * 67
// ```
//
// ---
// Question: {{.question}}
//
// 可以看到提示词并没有要求大模型直接计算检测结果,而是要求其将用户输入的数学问题翻译为
// 程序可识别的数学表达式,然后按照特定格式返回翻译出的数学表达式
func NewLLMMathChain(llm llms.Model) LLMMathChain {
p := prompts.NewPromptTemplate(_llmMathPrompt, []string{"question"})
c := NewLLMChain(llm, p)
return LLMMathChain{
LLMChain: c,
}
}
func (c LLMMathChain) Call(ctx context.Context, values map[string]any, options ...ChainCallOption) (map[string]any, error) { // nolint: lll
question, ok := values["question"].(string)
if !ok {
return nil, fmt.Errorf("%w: %w", ErrInvalidInputValues, ErrInputValuesWrongType)
}
// 大模型根据提示词提取用户输入中的数学表达式,并通过 output 返回
output, err := Call(ctx, c.LLMChain, map[string]any{
"question": question,
}, options...)
if err != nil {
return nil, err
}
// 将计算结果写入 output 的 answer 字段,GetOutputKeys 方法中返回的也是此字段
output["answer"], err = c.processLLMResult(output["text"].(string))
return output, err
}
func (c LLMMathChain) GetMemory() schema.Memory { //nolint:ireturn
// LLMMathChain 主要用于一次性计算数学表达式,不需要保存记忆信息
return memory.NewSimple()
}
func (c LLMMathChain) GetInputKeys() []string {
return []string{"question"}
}
func (c LLMMathChain) GetOutputKeys() []string {
return []string{"answer"}
}
var starlarkBlockRegex = regexp.MustCompile("(?s)```starlark(.*)```")
func (c LLMMathChain) processLLMResult(llmOutput string) (string, error) {
llmOutput = strings.TrimSpace(llmOutput)
// 使用正则表达式提取大模型输出中的数学表达式
textMatch := starlarkBlockRegex.FindStringSubmatch(llmOutput)
if len(textMatch) > 0 {
expression := textMatch[1]
// 使用 starlark.Eval 计算数学表达式
output, err := c.evaluateExpression(expression)
if err != nil {
return "", fmt.Errorf("evaluating expression: %w", err)
}
return output, nil
}
if strings.Contains(llmOutput, "Answer:") {
return strings.TrimSpace(strings.Split(llmOutput, "Answer:")[1]), nil
}
return "", fmt.Errorf("unknown format from LLM: %s", llmOutput) //nolint:goerr113
}
func (c LLMMathChain) evaluateExpression(expression string) (string, error) {
expression = strings.TrimSpace(expression)
v, err := starlark.Eval(&starlark.Thread{Name: "main"}, "input", expression, math.Module.Members)
if err != nil {
return "", err
}
return v.String(), nil
}
和前面提到的两个 Chain
相比,LLMMathChain
就不仅限于和大模型进行对话了,在自己的 Call
方法中增加了更多的逻辑,通过提示词要求大模型翻译用户提出的数学问题为标准的数学表达式,并按照特定模式返回表达式,比如这里就是要求按照如下格式进行返回,
starlark
37593 * 67
收到大模型返回的内容之后,就可以使用正则表达式提取其中的数学表达式,之后实际计算数学表达式的值,LLMMathChain
和前面两个 Chain
相比,最大的区别在于大模型并不直接对用户进行回复,而是起一个翻译用户输入为数学表达式的作用。
SequentialChain:让一系列Chain
顺序执行
Chain
内部也不一定非要和大模型产生交互,比如 SequentialChain
自身并不实现什么特定的功能,主要的作用就是将一系列的 Chain
顺序执行,这也直观地体现出了 Chain
设计的灵活性,可以简单地看下 SequentialChain
实现的 Call
方法:
go
func (c *SequentialChain) Call(ctx context.Context, inputs map[string]any, options ...ChainCallOption) (map[string]any, error) { //nolint:lll
var outputs map[string]any
var err error
for _, chain := range c.chains {
outputs, err = Call(ctx, chain, inputs, options...)
if err != nil {
return nil, err
}
inputs = outputs
}
return outputs, nil
}
此方法的核心逻辑就是将前一个 Chain
的输出作为下一个 Chain
的输入。
通过以上4个 Chain
的源码,相信你一定理解 Chain
到底是什么东西了,chains
包内部还有一些其他预置的实现,逻辑也很清晰,有兴趣可以读一下相关的源码,进一步加深对于 Chain
的理解。接下来我们来看 agents
包的源码,agents
会将我们之前所说的所有模块都联系起来,搭建一个完整的 ai agent。
agents包核心用法及源码解析:
在使用 langchaingo 等大模型开发框架时,我们的核心目标之一就是构建一个 AI Agent。所谓 AI Agent,指的是一种基于大语言模型构建的智能体,它可以在执行任务过程中自主地进行推理和行动。大多数 Agent 通常采用 ReAct 策略模型(Reasoning + Acting)来完成任务:它会"边想边做",即在每一步中先进行推理判断(Reasoning),然后决定是否调用外部工具或执行某个动作(Acting),并根据反馈持续调整策略,直至完成目标。
在 langchaingo 中,agents
包为开发者提供了构建 AI Agent 的能力,下面我们通过一个完整示例,体验一下 Agent 是如何工作、如何"边想边做"的。
go
func main() {
llm, err := openai.New(
openai.WithBaseURL("url"),
openai.WithToken("key"),
openai.WithModel("qwen-plus"),
)
if err != nil {
log.Fatal(err)
}
tool := []tools.Tool{
tools.Calculator{
CallbacksHandler: callbacks.LogHandler{},
},
}
agent := agents.NewConversationalAgent(llm, tool)
chain := agents.NewExecutor(
agent,
agents.WithMaxIterations(5),
agents.WithMemory(memory.NewConversationBuffer()),
agents.WithCallbacksHandler(callbacks.LogHandler{}),
)
ctx := context.Background()
output, err := chains.Run(ctx, chain, "帮我计算一下 998 x 999 x 100 的结果")
if err != nil {
log.Fatal(err)
}
log.Printf("agent output: %s\n", output)
output, err = chains.Run(ctx, chain, "将刚刚的计算结果除以2,再加上9999,结果是多少?")
if err != nil {
log.Fatal(err)
}
log.Printf("agent output: %s\n", output)
}
// output:
// Entering chain with inputs: "input" : "帮我计算一下 998 x 999 x 100 的结果",
// Agent selected action: "calculator" with input "998 * 999 * 100"
// Entering tool with input: 998 * 999 * 100
// Exiting tool with output: 99700200
// Agent finish: {map[output: 998 x 999 x 100 的计算结果是 99700200。] AI: 998 x 999 x 100 的计算结果是 99700200。}
// Exiting chain with outputs: "output" : " 998 x 999 x 100 的计算结果是 99700200。",
// agent output: 998 x 999 x 100 的计算结果是 99700200。
// Entering chain with inputs: "input" : "将刚刚的计算结果除以2,再加上9999,结果是多少?",
// Agent selected action: "calculator" with input "99700200 / 2 + 9999"
// Entering tool with input: 99700200 / 2 + 9999
// Exiting tool with output: 4.9860099e+07
// Agent finish: {map[output: 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。] Thought: Do I need to use a tool? No
// AI: 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。}
// Exiting chain with outputs: "output" : " 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。",
// agent output: 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。
在这段代码中,我们构建了一个 ConversationalAgent
,并给它注册了一个用于处理数学表达式的 Calculator
工具。同时我们为 Agent 配置了 Memory(对话上下文记忆),以及一个 LogHandler
Callback,用于打印执行日志,便于观察 Agent 的运行过程。
我们逐步来看执行过程中的关键日志,理解 ReAct 模式下 Agent 的行为逻辑。
python
agent最终也被包装为了Chain,执行入口是统一的,故最开始的日志是 chain 的输入参数:
Entering chain with inputs: "input" : "帮我计算一下 998 x 999 x 100 的结果"
之后agent判断需要使用 calculator 工具,同时 calculator 的参数指定为 "998 * 999 * 100":
Agent selected action: "calculator" with input "998 * 999 * 100"
之后calculator 工具被调用,输入参数也确实是大模型传入的参数:
Entering tool with input: 998 * 999 * 100
calculator 工具计算了这个表达式,并返回了计算结果:
Exiting tool with output: 99700200
得到工具计算的结果之后,agent认为不需要再调用其他工具了,于是返回了给用户的文案:
Agent finish: {map[output: 998 x 999 x 100 的计算结果是 99700200。] AI: 998 x 999 x 100 的计算结果是 99700200。}
chain调用结束,得到 agent 返回的结果,我们也将结果打印到了标准输出:
Exiting chain with outputs: "output" : " 998 x 999 x 100 的计算结果是 99700200。",
agent output: 998 x 999 x 100 的计算结果是 99700200。
接下来给agent发送了一个新的问题,chain得到输入参数:
Entering chain with inputs: "input" : "将刚刚的计算结果除以2,再加上9999,结果是多少?",
agent判断需要再次使用 calculator 工具,同时因为memory的存在,agent能够记住刚刚计算的结果,于是 agent 给 calculator 传入新的参数:
Agent selected action: "calculator" with input "99700200 / 2 + 9999"
接下来 calculator 工具被调用,并计算结果:
Entering tool with input: 99700200 / 2 + 9999
Exiting tool with output: 4.9860099e+07
得到工具计算的结果之后,agent判断不需要再调用其他工具了,于是返回了给用户的文案:
Agent finish: {map[output: 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。] Thought: Do I need to use a tool? No
AI: 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。}
chain调用结束,得到 agent 返回的结果,并将结果打印到了标准输出:
Exiting chain with outputs: "output" : " 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。",
agent output: 将刚刚的计算结果 99700200 除以 2,再加上 9999,最终的结果是 49860099。
通过上述日志,能够清晰地看到agent的整个执行过程,这也就是我们刚刚所说的边想边做,收到提示词之后,并不会让大模型直接返回给用户结果,而是先让大模型判断是否需要用哪个工具,比如这里就判断出需要用 calculator 工具,得到工具的结果后再次判断是应该直接返回给用户结果,还是继续使用工具,重复这个过程,直到大模型判断不需要再使用工具了,此时再将最终结果返回给用户。
当我第一次看到 Agent 可以"主动调用工具"时,感到非常神奇------因为我们通常理解的大语言模型(LLM),只会输出一段纯文本,它怎么可能"执行一段工具代码"呢?
其实背后的原理非常巧妙,但逻辑并不复杂,关键点在于,模型不会直接执行代码,而是"写出调用工具的意图",由框架识别并执行,具体过程如下:
-
大模型生成输出时,如果判断当前任务需要使用某个工具,它会在返回的文本中写出一个特殊格式的标记,其中包含:工具的名称(如 calculator),工具的输入参数(如 998 * 999 * 100)
-
框架收到模型输出后,会使用正则表达式等方法解析出这个标记,提取出模型"想要调用"的工具名称和参数。
-
如果能匹配到标记,框架就会查找之前注册的同名工具并调用该工具将结果返回
-
接着,大模型会基于工具返回的结果继续思考,直到它判断任务完成,然后返回最终结果。
那模型是怎么知道有哪些工具、怎么调用? 这也是实现的关键点:一切都藏在提示词(Prompt)中。
在这行代码中:agent := agents.NewConversationalAgent(llm, tool)
我们将一组工具注册给了 Agent。此时框架会自动生成一段提示词内容,大致包括:
-
当前有哪些工具可用(名称 + 描述)
-
每个工具应该怎么用(输入格式、参数说明)
-
如果模型想调用工具,要用什么格式的标记(比如 Action: calculator\nAction Input: 998 * 999 * 100)
-
如果不需要再调用工具、可以直接给出最终结果,则返回特定标记,如:Final Answer
换句话说,大模型并不是"会执行代码",而是"知道怎么写出一个请求让别人去执行",就像一个聪明的指挥官,只需要发出格式化指令,真正的执行由外部框架来完成。
这是一个整体的过程,接下来我们详细地过一遍 agent 相关的源码:
首先我们需要关注一个 agents
的包 Agent
接口,
go
type Agent interface {
Plan(ctx context.Context, intermediateSteps []schema.AgentStep, inputs map[string]string) ([]schema.AgentAction, *schema.AgentFinish, error) //nolint:lll
GetInputKeys() []string
GetOutputKeys() []string
GetTools() []tools.Tool
}
这里的核心是 Plan
方法,从函数的签名能够看出,这个方法在执行完当前步骤之后,决定了是进行新的action还是直接finish,所有的Agent都要实现这个接口,就以我们刚刚用的 ConversationalAgent
为例,详细看一下他的源码。
首先是对象创建部分:
go
func NewConversationalAgent(llm llms.Model, tools []tools.Tool, opts ...Option) *ConversationalAgent {
options := conversationalDefaultOptions()
for _, opt := range opts {
opt(&options)
}
return &ConversationalAgent{
Chain: chains.NewLLMChain(
llm,
options.getConversationalPrompt(tools),
chains.WithCallback(options.callbacksHandler),
),
Tools: tools,
OutputKey: options.outputKey,
CallbacksHandler: options.callbacksHandler,
}
}
这里的核心在于 options := conversationalDefaultOptions()
以及 options.getConversationalPrompt(tools)
,声明了发送给大模型的提示词:
go
func conversationalDefaultOptions() Options {
return Options{
promptPrefix: _defaultConversationalPrefix,
formatInstructions: _defaultConversationalFormatInstructions,
promptSuffix: _defaultConversationalSuffix,
outputKey: _defaultOutputKey,
}
}
func (co Options) getConversationalPrompt(tools []tools.Tool) prompts.PromptTemplate {
if co.prompt.Template != "" {
return co.prompt
}
return createConversationalPrompt(
tools,
co.promptPrefix,
co.formatInstructions,
co.promptSuffix,
)
}
func createConversationalPrompt(tools []tools.Tool, prefix, instructions, suffix string) prompts.PromptTemplate {
template := strings.Join([]string{prefix, instructions, suffix}, "\n\n")
return prompts.PromptTemplate{
Template: template,
TemplateFormat: prompts.TemplateFormatGoTemplate,
InputVariables: []string{"input", "agent_scratchpad"},
PartialVariables: map[string]any{
"tool_names": toolNames(tools),
"tool_descriptions": toolDescriptions(tools),
"history": "",
},
}
}
在 createConversationalPrompt
函数中,最终构造了提示词模板,提示词模板是由 prefix
、instructions
、suffix
通过 \n\n
连接而成的, 这三部分也是直接预置到agents包之中的,拼接后的完整提示词如下:
vbnet
Assistant is a large language model trained by Meta.
Assistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.
Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.
Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist.
TOOLS:
------
Assistant has access to the following tools:
{{.tool_descriptions}}
To use a tool, please use the following format:
Thought: Do I need to use a tool? Yes
Action: the action to take, should be one of [{{.tool_names}}]
Action Input: the input to the action
Observation: the result of the action
When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format:
Thought: Do I need to use a tool? No
AI: [your response here]
Begin!
Previous conversation history:
{{.history}}
New input: {{.input}}
Thought:{{.agent_scratchpad}}
其实看到这个提示词,我们基本就把一切都搞明白了,
markdown
TOOLS:
------
Assistant has access to the following tools:
{{.tool_descriptions}}
这部分告诉了大模型都能用哪些工具,以及每个工具的描述信息, 在 createConversationalPrompt
函数的 "tool_descriptions": toolDescriptions(tools)
中对提示词模板进行了赋值。
vbnet
To use a tool, please use the following format:
Thought: Do I need to use a tool? Yes
Action: the action to take, should be one of [{{.tool_names}}]
Action Input: the input to the action
Observation: the result of the action
这部分告诉了大模型如果想要使用工具,就需要加上一个 Action
标记,Action
后面为想要使用的工具的名称,同时在 Action Input
后面指定工具的输入参数,在框架执行完工具之后,会在 Observation
标记之后写明工具的执行结果。
vbnet
When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format:
Thought: Do I need to use a tool? No
AI: [your response here]
这里告诉大模型,如果判断不需要再使用工具了,就加一个 AI
标记,后面写明自己希望最终回复给用户的文案。
css
Previous conversation history:
{{.history}}
New input: {{.input}}
Thought:{{.agent_scratchpad}}
这里告诉了大模型历史对话记录、新的用户输入以及大模型每一步的思考结果。
指定好提示词之后,看下 ConversationalAgent
的 Plan
方法:
go
func (a *ConversationalAgent) Plan(
ctx context.Context,
intermediateSteps []schema.AgentStep,
inputs map[string]string,
) ([]schema.AgentAction, *schema.AgentFinish, error) {
fullInputs := make(map[string]any, len(inputs))
for key, value := range inputs {
fullInputs[key] = value
}
// 将之前执行的步骤整理为一个字符串,记录了大模型的思考过程,
// 会渲染提示词中的 Thought:{{.agent_scratchpad}} 部分
fullInputs["agent_scratchpad"] = constructScratchPad(intermediateSteps)
var stream func(ctx context.Context, chunk []byte) error
if a.CallbacksHandler != nil {
stream = func(ctx context.Context, chunk []byte) error {
a.CallbacksHandler.HandleStreamingFunc(ctx, chunk)
return nil
}
}
// 将提示词发送给了大模型
output, err := chains.Predict(
ctx,
a.Chain,
fullInputs,
chains.WithStopWords([]string{"\nObservation:", "\n\tObservation:"}),
chains.WithStreamingFunc(stream),
)
if err != nil {
return nil, nil, err
}
// 解析大模型的返回值
return a.parseOutput(output)
}
const (
_conversationalFinalAnswerAction = "AI:"
)
func (a *ConversationalAgent) parseOutput(output string) ([]schema.AgentAction, *schema.AgentFinish, error) {
// 判断返回值中是否包含了调用结束标志,AI: ,这也是在提示词中指定的结束标志
if strings.Contains(output, _conversationalFinalAnswerAction) {
// 如果包含,取 AI: 之后的部分作为最终返回值,返回 schema.AgentFinish 表明 ReAct 结束
splits := strings.Split(output, _conversationalFinalAnswerAction)
finishAction := &schema.AgentFinish{
ReturnValues: map[string]any{
a.OutputKey: splits[len(splits)-1],
},
Log: output,
}
return nil, finishAction, nil
}
// 若不包含结束标志,则正则匹配 Action 与 Action Input,获取大模型接下来想进行的操作
r := regexp.MustCompile(`Action: (.*?)[\n]*Action Input: (.*)`)
matches := r.FindStringSubmatch(output)
if len(matches) == 0 {
return nil, nil, fmt.Errorf("%w: %s", ErrUnableToParseOutput, output)
}
// 返回 schema.AgentAction,表明下一步要进行的 Action,指定了工具名称以及工具的参数
return []schema.AgentAction{
{Tool: strings.TrimSpace(matches[1]), ToolInput: strings.TrimSpace(matches[2]), Log: output},
}, nil, nil
}
这些就是 ConversationalAgent
最核心的逻辑了。接下来我们还需要看下 agents
包的 Executor
对象的相关逻辑,因为 Agent
对象都是通过 agents.NewExecutor
包装为 Chain
对象来实际启动起来的,以下为 Executor
对象的 Call
方法:
go
func (e *Executor) Call(ctx context.Context, inputValues map[string]any, _ ...chains.ChainCallOption) (map[string]any, error) { //nolint:lll
inputs, err := inputsToString(inputValues)
if err != nil {
return nil, err
}
nameToTool := getNameToTool(e.Agent.GetTools())
// 进入循环,最大循环次数为 e.MaxIterations
steps := make([]schema.AgentStep, 0)
for i := 0; i < e.MaxIterations; i++ {
var finish map[string]any
// 执行本次循环,若发现循环结束,则返回 finish,否则继续循环
steps, finish, err = e.doIteration(ctx, steps, nameToTool, inputs)
if finish != nil || err != nil {
return finish, err
}
}
if e.CallbacksHandler != nil {
e.CallbacksHandler.HandleAgentFinish(ctx, schema.AgentFinish{
ReturnValues: map[string]any{"output": ErrNotFinished.Error()},
})
}
return e.getReturn(
&schema.AgentFinish{ReturnValues: make(map[string]any)},
steps,
), ErrNotFinished
}
func (e *Executor) doIteration( // nolint
ctx context.Context,
steps []schema.AgentStep,
nameToTool map[string]tools.Tool,
inputs map[string]string,
) ([]schema.AgentStep, map[string]any, error) {
// 调用 Agent 的 Plan 方法,这个 steps 里面会包含 agent 每一次迭代的思考内容
actions, finish, err := e.Agent.Plan(ctx, steps, inputs)
if errors.Is(err, ErrUnableToParseOutput) && e.ErrorHandler != nil {
formattedObservation := err.Error()
if e.ErrorHandler.Formatter != nil {
formattedObservation = e.ErrorHandler.Formatter(formattedObservation)
}
steps = append(steps, schema.AgentStep{
Observation: formattedObservation,
})
return steps, nil, nil
}
if err != nil {
return steps, nil, err
}
if len(actions) == 0 && finish == nil {
return steps, nil, ErrAgentNoReturn
}
// 如果发现可以结束了,则不再执行工具,返回最终结果
if finish != nil {
if e.CallbacksHandler != nil {
e.CallbacksHandler.HandleAgentFinish(ctx, *finish)
}
return steps, e.getReturn(finish, steps), nil
}
// 执行所有需要执行的工具
for _, action := range actions {
steps, err = e.doAction(ctx, steps, nameToTool, action)
if err != nil {
return steps, nil, err
}
}
return steps, nil, nil
}
func (e *Executor) doAction(
ctx context.Context,
steps []schema.AgentStep,
nameToTool map[string]tools.Tool,
action schema.AgentAction,
) ([]schema.AgentStep, error) {
if e.CallbacksHandler != nil {
e.CallbacksHandler.HandleAgentAction(ctx, action)
}
// 基于工具名称取出工具,并调用工具的 Call 方法
tool, ok := nameToTool[strings.ToUpper(action.Tool)]
if !ok {
return append(steps, schema.AgentStep{
Action: action,
Observation: fmt.Sprintf("%s is not a valid tool, try another one", action.Tool),
}), nil
}
observation, err := tool.Call(ctx, action.ToolInput)
if err != nil {
return nil, err
}
return append(steps, schema.AgentStep{
Action: action,
Observation: observation,
}), nil
}
Executor
的 Call
方法还是非常清晰的,核心就是一个循环,每一次循环代表大模型进行一次思考,是调用工具?还是返回最终结果?接下来我复制一段debug过程中的发送给大模型的提示词,更加直观地看一下是如何通过提示词中的标记决定agent下一步行动的:
vbnet
TOOLS:
------
Assistant has access to the following tools:
- calculator: Useful for getting the result of a math expression.
The input to this tool should be a valid mathematical expression that could be executed by a starlark evaluator.
To use a tool, please use the following format:
Thought: Do I need to use a tool? Yes
Action: the action to take, should be one of [calculator]
Action Input: the input to the action
Observation: the result of the action
When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format:
Thought: Do I need to use a tool? No
AI: [your response here]
Begin!
Previous conversation history:
New input: 帮我计算一下 998 x 999 x 100 的结果
Thought:Do I need to use a tool? Yes
Action: calculator
Action Input: 998 * 999 * 100
Observation: 99700200
Thought:
这里可以看到提示词中包含了注册的外部工具,也包含了上一步调用 calculator
工具的计算结果,基于这些上下文信息大模型就可以考虑下一步是继续用工具还是回复用户最终结果了。
agents
包的源码基本就介绍完了,agents
在langchaingo中属于更加偏向业务的上层模块,主要作用就是便于发者快速构建出一个ai agent。接下来我们再看一个相对较轻但也非常重要的模块 callbacks
。
callbacks包核心用法及源码解析:
callbacks
包功能比较单一,就是在整个 langchaingo
框架的执行链路中嵌入各种回调,监听框架的各种事件,比如我们上面关于 agents
包的示例代码中就添加了 callbacks.LogHandler
用以监听框架执行的事件,这个概念应该很好理解,和大多数开发框架的 callback 的概念是一致的。
callbacks
包中定义了一个核心的 Handler
接口,用以描述都可以监听框架执行过程中的哪些事件,接口定义如下:
go
type Handler interface {
HandleText(ctx context.Context, text string)
HandleLLMStart(ctx context.Context, prompts []string)
HandleLLMGenerateContentStart(ctx context.Context, ms []llms.MessageContent)
HandleLLMGenerateContentEnd(ctx context.Context, res *llms.ContentResponse)
HandleLLMError(ctx context.Context, err error)
HandleChainStart(ctx context.Context, inputs map[string]any)
HandleChainEnd(ctx context.Context, outputs map[string]any)
HandleChainError(ctx context.Context, err error)
HandleToolStart(ctx context.Context, input string)
HandleToolEnd(ctx context.Context, output string)
HandleToolError(ctx context.Context, err error)
HandleAgentAction(ctx context.Context, action schema.AgentAction)
HandleAgentFinish(ctx context.Context, finish schema.AgentFinish)
HandleRetrieverStart(ctx context.Context, query string)
HandleRetrieverEnd(ctx context.Context, query string, documents []schema.Document)
HandleStreamingFunc(ctx context.Context, chunk []byte)
}
可以看到包含框架执行过程中的各个位置的回调。
刚刚所用到的 LogHandler
具体实现如下:
go
func (l LogHandler) HandleLLMStart(_ context.Context, prompts []string) {
fmt.Println("Entering LLM with prompts:", prompts)
}
func (l LogHandler) HandleLLMError(_ context.Context, err error) {
fmt.Println("Exiting LLM with error:", err)
}
func (l LogHandler) HandleChainStart(_ context.Context, inputs map[string]any) {
fmt.Println("Entering chain with inputs:", formatChainValues(inputs))
}
func (l LogHandler) HandleChainEnd(_ context.Context, outputs map[string]any) {
fmt.Println("Exiting chain with outputs:", formatChainValues(outputs))
}
func (l LogHandler) HandleChainError(_ context.Context, err error) {
fmt.Println("Exiting chain with error:", err)
}
func (l LogHandler) HandleToolStart(_ context.Context, input string) {
fmt.Println("Entering tool with input:", removeNewLines(input))
}
func (l LogHandler) HandleToolEnd(_ context.Context, output string) {
fmt.Println("Exiting tool with output:", removeNewLines(output))
}
LogHandler
作用就是将全部事件的执行信息打印到标准输出,代码也很好理解,就是用 fmt.Println
打印。
Handler
接口中需要单独关注的是 HandleStreamingFunc(ctx context.Context, chunk []byte)
方法,我们刚刚的代码示例其实所有的输出都不是流式的,都是大模型完整输出完,然后一次性返回完整的对话信息,这种体验是比较差的,因为大模型输出的过程真的可以说是很慢,让用户长时间等待是不可接受的,会有一种页面卡死的感觉,所以目前的大模型应用也都是流式返回大模型的回复。但我们刚刚的代码示例中是做不到这点的,很多接口定义的方法返回的数据都是 map[string]any
,并不支持流式返回数据,langchaingo
中流式输出的解决方案就是实现 HandleStreamingFunc
方法,监听输出的分块数据,然后将数据流式返回给用户,示例代码如下:
go
func main() {
ctx := context.Background()
llm, err := ollama.New(ollama.WithModel("llama3.2"))
if err != nil {
panic(err)
}
prompt := "你好,你是谁?"
_, err = llms.GenerateFromSinglePrompt(ctx, llm, prompt, llms.WithStreamingFunc(func(ctx context.Context, chunk []byte) error {
fmt.Printf("[%s]", string(chunk))
return nil
}))
if err != nil {
panic(err)
}
}
// output:
// [我][是][艾][尔][iya][,][一个][人][工][智能][助][手][。][我的][主要][功能][是][帮助][回答][问题][、][提供][信息][和][解决][问题]
// [。][您][可以][与][我][交][谈][,][问][我][任何][问题][或][需要][帮助][的][东西][,我][都会][尽][力][为][您][提供][最][好的][帮助][。][]
在 GenerateFromSinglePrompt
函数中添加了一个 StreamingFunc
,此时我们就不再关注函数的返回值了,而是直接在 StreamingFunc
中将大模型的回复流式打印到标准输出。
StreamingFunc
的调用时机之前介绍 llms
包的时候曾经提到过,现在可以再看一下:
go
func parseStreamingChatResponse(ctx context.Context, r *http.Response, payload *ChatRequest) (*ChatCompletionResponse,
error,
) { //nolint:cyclop,lll
scanner := bufio.NewScanner(r.Body)
responseChan := make(chan StreamedChatResponsePayload)
go func() {
defer close(responseChan)
for scanner.Scan() {
// 逐行读取返回值
line := scanner.Text()
if line == "" {
continue
}
// sse协议规定数据必须以 data: 开头,故此处删除统一的前缀
data := strings.TrimPrefix(line, "data:") // here use `data:` instead of `data: ` for compatibility
data = strings.TrimSpace(data)
if data == "[DONE]" {
return
}
// 将数据读取结果写入管道
var streamPayload StreamedChatResponsePayload
err := json.NewDecoder(bytes.NewReader([]byte(data))).Decode(&streamPayload)
if err != nil {
streamPayload.Error = fmt.Errorf("error decoding streaming response: %w", err)
responseChan <- streamPayload
return
}
responseChan <- streamPayload
}
if err := scanner.Err(); err != nil {
responseChan <- StreamedChatResponsePayload{Error: fmt.Errorf("error reading streaming response: %w", err)}
return
}
}()
// 在此函数中处理管道中的值
return combineStreamingChatResponse(ctx, payload, responseChan)
}
func combineStreamingChatResponse(
ctx context.Context,
payload *ChatRequest,
responseChan chan StreamedChatResponsePayload,
) (*ChatCompletionResponse, error) {
response := ChatCompletionResponse{
Choices: []*ChatCompletionChoice{
{},
},
}
for streamResponse := range responseChan {
....
// 读取管道中的内容,并分块传输给 StreamingFunc
if payload.StreamingFunc != nil {
err := payload.StreamingFunc(ctx, chunk)
if err != nil {
return nil, fmt.Errorf("streaming func returned an error: %w", err)
}
}
}
return &response, nil
}
此处实现逻辑就是分行读取大模型的返回值,然后通过管道回调 StreamingFunc
,业务侧即可通过 StreamingFunc
进行流式输出。
接下来我们看一个很常用的 callback
go
func main() {
llm, err := openai.New(
openai.WithBaseURL("url"),
openai.WithToken("key"),
openai.WithModel("qwen-plus"),
)
if err != nil {
log.Fatal(err)
}
tool := []tools.Tool{
tools.Calculator{},
}
streamHandler := callbacks.NewFinalStreamHandler()
agent := agents.NewConversationalAgent(llm, tool, agents.WithCallbacksHandler(streamHandler))
egress := streamHandler.GetEgress()
go func() {
for v := range egress {
fmt.Printf("[%s]", string(v))
}
}()
chain := agents.NewExecutor(
agent,
agents.WithMaxIterations(5),
agents.WithMemory(memory.NewConversationBuffer()),
)
ctx := context.Background()
_, err = chains.Run(ctx, chain, "帮我计算一下 998 x 999 x 100 的结果")
if err != nil {
log.Fatal(err)
}
}
// output:
// [][998][ x][ 999 x ][100 的计算][结果是 997][00200。][]
通过 NewFinalStreamHandler
创建了一个 AgentFinalStreamHandler
,其实从类型名称就能感受出来,AgentFinalStreamHandler
只会流式输出 agent 的最终回复,而不会流式输出思考内容,比如大模型回复判断需要使用哪个工具,这部分内容就不会被流式输出,这很有用,用户肯定不希望看到这么多中间步骤,所以langchaingo就将 AgentFinalStreamHandler
内置到 callbacks
包了,从我们的示例代码来看也确实只流式输出了最终结果。
接下来看下 AgentFinalStreamHandler
的源码:
go
// GetEgress 获取管道,监听此管道即可流式获取agent最终给用户的回复
func (handler *AgentFinalStreamHandler) GetEgress() chan []byte {
return handler.egress
}
// ReadFromEgress 此方法的本意是想提供一个工具函数,但close无法被执行到,导致协程泄露了
func (handler *AgentFinalStreamHandler) ReadFromEgress(
ctx context.Context,
callback func(ctx context.Context, chunk []byte),
) {
go func() {
defer close(handler.egress)
for data := range handler.egress {
callback(ctx, data)
}
}()
}
// 实现 HandleStreamingFunc 方法
func (handler *AgentFinalStreamHandler) HandleStreamingFunc(_ context.Context, chunk []byte) {
// LastTokens 保存了全部的返回数据,以便于从 LastTokens 中判断是否出现了最终回复的关键字
chunkStr := string(chunk)
handler.LastTokens += chunkStr
var detectedKeyword string
// 得到关键字的最大长度,这里的关键字就是判断大模型返回内容为最终回复的关键字,
// 比如示例代码中的 ConversationalAgent
// 最终回复的关键字就是 'AI:' ,这个关键字也包含于 handler.Keywords 之中
var longestSize int
for _, k := range handler.Keywords {
if len(k) > longestSize {
longestSize = len(k)
}
}
// 依次判断到目前为止大模型的回复中是否出现了关键字
for _, k := range DefaultKeywords {
if strings.Contains(handler.LastTokens, k) {
handler.KeywordDetected = true
detectedKeyword = k
}
}
// 这里移除了 LastTokens 前面缓存的数据,算是一个性能优化,避免了 LastTokens 全量保存所有回复
// 其实只要不把关键字破坏掉,就可以删除之前缓存的数据,所以这里取 len(handler.LastTokens)-longestSize
// 之后的数据,避免把关键字先出现的一部分移除掉
if len(handler.LastTokens) > longestSize {
handler.LastTokens = handler.LastTokens[len(handler.LastTokens)-longestSize:]
}
// 检测到最终回复关键字
if handler.KeywordDetected && !handler.PrintOutput {
// 此处移除了 chunkStr 包含的关键字部分,比如 AI: ,去掉前缀,这部分也是不用返回给用户的
chunk = []byte(filterFinalString(chunkStr, detectedKeyword))
// 标记需要输出
handler.PrintOutput = true
}
// 将最终回复关键字之后的内容写入管道,业务代码可从管道分块获取最终回复
if handler.PrintOutput {
handler.egress <- chunk
}
}
AgentFinalStreamHandler
的实现逻辑也是比较清晰的,核心就是对象的 LastTokens
属性缓存大模型的回复,每收到一段新的回复就判断 LastTokens
是否包含最终回复的关键字,若存在就标记已出现终止关键字,将后续的内容写入 egress
管道,业务侧即可从 egress
管道流式获取大模型的最终回复。
不过 ReadFromEgress
方法显然是有问题的,方法内部启动协程监听 egress
管道,但 egress
管道没有关闭逻辑,defer 中的 close 也永远执行不到,即使大模型的回复已经结束了,ReadFromEgress
启动的协程还是会一直阻塞在读取管道中的数据,最终导致协程泄露, 实际上已经有人提交了修复的PR,github.com/tmc/langcha... 。整体来看 callbacks
包还是很好理解的,就是可以注册框架全链路各个事件的回调函数,同时基于 StreamingFunc
实现了大模型回复的流式输出。
到这里,基本就把 langchaingo 框架的设计理念、运行原理和核心源码都走了一遍。像 embeddings
、vectorstores
、outputparser
这些相对独立的功能包,并不在框架的核心链路中,我就不展开介绍了,有兴趣的读者可以直接读源码,结构依然相当清晰。
我个人很喜欢 langchaingo 这种轻量、显式的风格。相比之下,Python 版 LangChain 作为当前最成熟的大模型开发框架,已经提供了非常完善的思路。从这个角度看,langchaingo 只要延续 LangChain 的设计理念,就能少走许多弯路。 当然,目前 langchaingo 依然有不少 issue 和 PR 长期悬而未决,这难免让人有些沮丧。无论如何,还是衷心希望它能尽快解决遗留问题,也希望 Go 生态的大模型开发框架越来越多、越来越成熟。接下来我可能会写一些 mcp-go 相关的文章,毕竟 MCP 确实让人看到了大模型走向成熟智能助理的明确方向。