0%

LangChainGo 学习 02:架构原理

一个大模型应用最初可能只有几十行代码:接收问题,发送请求,输出回答。加入多轮对话后,需要保存历史消息;加入企业文档后,需要先查找相关资料;再接入报销系统,就需要查询业务数据、处理工具结果。模型调用始终存在,但应用的大部分工作逐渐变成了这些步骤之间的配合。

LangChainGo 是 LangChain 的 Go 实现。它把模型接入、提示词、流程编排、对话记忆、文档检索和工具调用拆成不同组件,通过接口组合起来。本文以一个报销制度问答应用为线索,沿着请求的执行过程,介绍这些组件的职责与实现。

整体结构

LangChainGo 作为 Go 库运行在应用进程中。应用创建所需组件,再调用它们;模型服务、向量数据库和业务系统位于这些组件的另一端。

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
[命令行程序 / HTTP 服务 / 后台任务]
|
v
[chains 流程编排] <----- [memory 对话历史]
|
+--------+--------+
| |
v v
[prompts 提示词模板] [Retriever 文档查询]
| |
v v
[llms.Model] [vectorstores + embeddings]
| |
v v
[模型服务适配器] [向量数据库 / 向量服务]
|
v
[模型服务]

[agents.Executor] ---> [Agent.Plan 选择下一步]
| ^
v |
[tools 工具] -------- [工具执行结果]
|
v
[业务系统 / 搜索服务 / 计算逻辑]

图中有两种常见流程。普通问答按照事先安排的步骤执行:准备提示词、调用模型、解析回答。智能体则多了一个判断过程:模型可能先选择工具,程序执行后,再让模型决定继续调用工具还是给出答案。

这些能力可以分别采用。一个摘要接口只需要模型和提示词;制度问答还需要文档检索;查询报销进度时,再加入访问业务系统的工具。理解架构的关键,是看清每个步骤接收什么、返回什么,以及由谁安排执行顺序。

目录地图

仓库主要按能力组织包,公共接口和具体接入实现分开存放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
langchaingo/
|-- llms/ 模型接口、消息、调用选项
| |-- openai/ OpenAI 接入及内部 HTTP client
| |-- anthropic/ Anthropic 接入及内部 HTTP client
| |-- ollama/ Ollama 接入
| `-- cache/ 模型响应缓存
|-- prompts/ 字符串模板、聊天模板、模板渲染
|-- outputparser/ 模型输出解析
|-- chains/ 模型链、文档问答、文档组合等流程
|-- memory/ 对话历史及不同保留策略
|-- documentloaders/ 文本、HTML、PDF 等资料读取
|-- textsplitter/ 长文本切分
|-- embeddings/ 文本向量生成
|-- vectorstores/ 向量存储接入及检索器
|-- agents/ 智能体决策与执行循环
|-- tools/ 工具接口和具体工具
|-- schema/ 文档、记忆、检索等共享类型与接口
|-- callbacks/ 模型、链、工具等执行事件
`-- httputil/ HTTP client、Transport 等公共能力

例如,chains.LLMChain 持有 llms.Model,并没有指定必须使用哪个模型服务。chains.RetrievalQA 持有 schema.Retriever,也没有指定必须使用哪种向量数据库。具体实现由创建组件的代码传进去。

schema 集中了多个模块需要共同理解的对象,如文档、历史消息存储接口、检索接口和智能体步骤。模型消息与调用接口则主要定义在 llms 中。阅读源码时,可以先看这些约定,再进入某个具体实现。

模型接入

统一接口

不同模型服务的请求格式、消息角色和响应字段各不相同。如果问答流程直接处理这些差异,更换服务就会牵动提示词、记忆和工具调用代码。LangChainGo 通过 llms.Model 将模型调用集中到一个入口。

下面是 llms/llms.go 中的接口,省略了注释:

1
2
3
4
type Model interface {
GenerateContent(ctx context.Context, messages []MessageContent, options ...CallOption) (*ContentResponse, error)
Call(ctx context.Context, prompt string, options ...CallOption) (string, error)
}

GenerateContent 接收消息序列,返回统一的响应对象。Call 是为兼容早期代码保留的字符串接口,源码已将它标记为弃用;新的单次文本调用可以使用 GenerateFromSinglePrompt,它会把文本包装成一条用户消息,再调用 GenerateContent。

这个接口统一了调用方式,但具体模型支持哪些输入和参数,仍然由模型服务及对应实现决定。例如,具备图片内容类型,不代表所有模型都支持图片。

消息与响应

一条消息可能同时包含文字、图片或工具结果,单个字符串无法完整描述。为此,llms/generatecontent.go 将消息拆成角色和内容片段:

1
2
3
4
type MessageContent struct {
Role ChatMessageType
Parts []ContentPart
}

Role 表示消息来自系统、用户、模型或工具;Parts 保存具体内容。常见片段包括 TextContent、ImageURLContent、BinaryContent、ToolCall 和 ToolCallResponse。

响应则放在 ContentResponse.Choices 中。每个 ContentChoice 可以包含回答文本、停止原因、用量信息以及工具调用。这样,普通问答可以读取文本,工具执行流程可以读取工具名称和参数。

读取响应时,还要留意不同服务对 Choices 的组织方式。OpenAI 实现通常用它表示候选回答,Anthropic 实现则将响应中的内容块逐项转换为 choice。需要处理工具或多种内容时,应结合具体实现读取,不能一律只取第一项文本。

服务适配

发送请求前,适配器会把消息转换成模型服务要求的格式。以 llms/openai/openaillm.go 为例,GenerateContent 主要完成以下工作:

  • 应用本次调用的选项,确定模型和生成参数。
  • 将 human、AI 等消息角色转换为服务使用的 user、assistant 等角色。
  • 整理图片、工具调用和工具结果,生成请求结构。
  • 调用内部 client,再把响应转换为 ContentResponse。

内部 client 位于 llms/openai/internal/openaiclient,负责构造 URL、设置请求头、发送 HTTP 请求和读取响应。Anthropic 也采用了外层消息转换、内层 HTTP client 的分工,但具体消息格式和事件解析各自实现。

配置也分为两类:构造函数的选项用于设置密钥、基础地址、默认模型和 HTTP client;llms.CallOption 用于设置本次生成的参数,例如输出长度和流式回调。创建模型对象后,同一个对象可以被多个流程使用,每次调用再传入各自的选项。

提示词与解析

模型接通以后,下一个问题是如何稳定地组织输入和读取输出。以报销制度问答为例,固定要求是“根据资料回答”,变化的部分是用户问题和查到的制度内容。prompts 管理这些输入,outputparser 处理模型返回的文本。

提示词模板

prompts/prompt_template.go 中,NewPromptTemplate 默认采用 Go template 语法。Format 先合并预设变量和本次输入,再交给模板渲染器;同名变量以本次输入为准。

模板中的 {{.question}}、{{.context}} 因此可以分别填入问题与资料。Go 模板渲染器设置了 missingkey=error,缺少变量时会返回错误,避免把不完整的提示词继续发给模型。

提示词有两种常见形式:一种是“请根据资料回答这个问题”这样的整段文本,另一种是分别包含系统指令和用户问题的消息列表。为了让后续组件能使用这两种模板,字符串模板和聊天模板都提供了 FormatPrompt(values) 方法:填入变量后,返回一个 PromptValue,表示已经准备好的提示词。

PromptValue 定义在 llms/prompts.go,提供两种读取结果的方式:

1
2
3
4
type PromptValue interface {
String() string
Messages() []ChatMessage
}
  • String():将提示词作为一段文本读取。
  • Messages():将提示词作为带角色的消息列表读取。

两种模板返回的具体对象不同,转换方式也不同:

模板结果 String() 的结果 Messages() 的结果
StringPromptValue 原始文本 将整段文本放入一条 human 消息
ChatPromptValue 将各条消息加上角色前缀,拼成文本 保留各条消息及其角色

例如,聊天模板生成了“system:根据报销制度回答”和“human:报销需要哪些材料”两条消息。调用 Messages() 会得到这两条独立消息;调用 String() 则得到一段包含角色前缀的文本。后续组件可以按需要读取结果,再发起模型调用。

输出解析

如果程序只负责显示回答,读取字符串就够了。但如果下一步需要从回答中提取字段、评分或列表,就需要一层解析逻辑。

schema.OutputParser 提供 Parse 和 ParseWithPrompt 等方法。LLMChain 默认使用 outputparser.Simple,其 ParseWithPrompt 实现只有一次字符串处理:

1
2
3
func (p Simple) ParseWithPrompt(text string, _ llms.PromptValue) (any, error) {
return strings.TrimSpace(text), nil
}

默认解析器只去掉文本首尾的空白。需要更严格的结果时,可以替换解析器;解析失败会返回错误,由调用方决定如何处理。这里区分了两项工作:提示词表达期望格式,解析器检查模型实际返回了什么。

链的执行

模板和模型可以单独使用,但完整问答还包含读取历史、检查输入、解析回答和保存结果等步骤。如果每个接口都重新写一遍,处理顺序很容易出现差异。chains 将这些步骤组织成可复用的执行流程。

调用约定

chains/chains.go 定义了 Chain,省略注释后如下:

1
2
3
4
5
6
type Chain interface {
Call(ctx context.Context, inputs map[string]any, options ...ChainCallOption) (map[string]any, error)
GetMemory() schema.Memory
GetInputKeys() []string
GetOutputKeys() []string
}

输入输出通过具名字段传递。例如,普通模型链可以接收 question,返回 text;文档问答链接收 query,内部再准备问题和文档。GetInputKeys、GetOutputKeys 说明需要哪些字段,执行时据此检查字段是否齐全。具体字段的类型通常还要由各个链自行检查。

包级函数 chains.Call(ctx, chain, inputs) 负责安排公共步骤,执行顺序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
调用者输入
|
v
复制输入,加载并合并记忆变量
|
v
检查输入字段
|
v
执行 chain.Call
|
v
检查输出字段
|
v
保存本轮输入与输出
|
v
返回结果

这里的 chains.Call 是包里的函数,chain.Call 是某个链对象的方法。前者安排完整流程,后者执行该链自己的工作。因此,业务代码通常调用前者;只有一个输入和一个字符串输出时,还可以使用 chains.Run,它内部仍然经过 chains.Call。

模型链

最基础的实现是 LLMChain。它持有提示词模板、模型、记忆和解析器,执行主体见 chains/llm.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
}

finalOutput, err := c.OutputParser.ParseWithPrompt(result, promptValue)
if err != nil {
return nil, err
}

return map[string]any{c.OutputKey: finalOutput}, nil

这段代码对应三个连续步骤:生成提示词,调用模型,解析回答。默认输出字段是 text。源码中也能看到,当前这条调用路径使用 promptValue.String(),最终走单条文本提示的调用方式。

NewLLMChain 默认装入 memory.Simple。这个实现的加载和保存方法都不记录内容,因此,创建模型链本身不会自动获得对话记忆。需要连续对话时,再显式提供保存历史的实现。

更复杂的链也沿用同一个接口。例如,文档问答链在内部调用模型链,智能体执行器本身也实现 Chain。这样,外层可以复用相同的执行入口,而各个对象负责不同的具体步骤。

对话记忆

用户先问“报销需要哪些材料”,接着问“电子发票也可以吗”,第二个问题依赖上一轮内容。模型 API 的一次调用通常需要程序主动附上历史。LangChainGo 将这项工作拆成 如何组织历史 和 历史保存在哪里 两部分。

历史存储

schema.ChatMessageHistory 约定消息的添加、读取、替换和清空。默认实现位于 memory/chat.go,实际保存的是一个切片:

1
2
3
type ChatMessageHistory struct {
messages []llms.ChatMessage
}

AddUserMessage、AddAIMessage 向切片追加内容,Messages 返回历史。这个对象的生命周期跟随应用进程。需要持久化时,可以用 memory/sqlite3、memory/mongo 等目录中的实现替换,再通过 WithChatHistory 传给记忆组件。

记忆策略

ConversationBuffer 在历史存储上增加了两项处理,源码位于 memory/buffer.go:

  • 加载:读取消息,默认转成带 Human、AI 前缀的文本,放入 history 变量。设置 ReturnMessages 后,可以返回消息列表。
  • 保存:从本轮输入和输出中取出字符串,分别追加为用户消息和 AI 消息。输入输出存在多个字段时,可以指定 InputKey 和 OutputKey。

这就解释了记忆与链的连接方式:chains.Call 加载 history,模板把它放进提示词;调用结束后,chains.Call 再通知记忆组件保存本轮内容。chains.NewConversation 则预先准备了使用 history 和 input 的模板,将这些部件连接好。

对话变长后,可以换用保留最近若干轮消息的 ConversationWindowBuffer,或按 token 数裁剪的 ConversationTokenBuffer。前者在当前实现中按每轮两条消息计算窗口;后者使用 llms.CountTokens 估算历史文本长度,超过限制时从最早的消息开始删除。这些策略控制历史规模,实际发送的完整提示词还包括问题和其他说明。

默认历史对象只是一个没有锁的切片。HTTP 应用需要自己按会话分配历史对象或存储标识,并安排同一会话的并发访问;否则多个用户的历史可能进入同一个提示词。会话归属由应用管理,记忆组件负责按约定读写消息。

文档问答

保存对话只能解决“前面说过什么”。要回答企业自己的报销规定,还需要把制度文档提供给模型。文档很多时,应先找出相关段落,再把问题和资料一起送入模型,这就是检索增强生成,通常简称 RAG。

文档准备

读取、切分和存储属于文档准备阶段,可以在资料更新时执行;查询和回答则发生在用户提问时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
文档准备
文件 / 网页 --> Loader --> Document --> TextSplitter --> 文档片段
|
v
Embedder
|
v
向量数据库

问题回答
用户问题 --> Embedder --> 相似度查询 --> 相关文档片段
| |
+----------------+-----------------------+
|
v
提示词模板 --> 模型 --> 回答

documentloaders.Loader 将不同来源转换为 []schema.Document。例如,文本 loader 从 io.Reader 读取内容,PDF 和 HTML loader 各自处理对应格式。后续组件面对的都是同一种文档结构,定义在 schema/documents.go:

1
2
3
4
5
type Document struct {
PageContent string
Metadata map[string]any
Score float32
}

正文放在 PageContent;文件名、来源地址等可以放在 Metadata,具体信息取决于读取实现与应用补充;Score 可用于记录检索得分。

长文档随后交给 textsplitter。RecursiveCharacter 按空行、换行、空格等分隔符逐步拆分过长内容,并尝试合并成接近目标大小的片段。默认长度函数是 Unicode 字符计数,token splitter 则使用分词结果计算长度。SplitDocuments 为每个片段复制原文档的 metadata,使来源信息能继续传递。

向量与检索

切分以后,需要用数值表示文本,才能查找语义相近的段落。embeddings.Embedder 分别提供文档批量向量化和单个问题向量化。通用的 EmbedderImpl 将批处理交给 EmbedderClient,向量由接入的模型或服务实际生成。

向量存储负责把片段正文、向量和 metadata 关联起来。以 vectorstores/pgvector/pgvector.go 为例,AddDocuments 取出正文并调用 EmbedDocuments,随后将文档、向量和 metadata 批量写入 PostgreSQL;SimilaritySearch 则先调用 EmbedQuery,再执行向量距离查询。

可见,在这个实现中,调用者传入文档和问题文本,store 内部会使用配置的 embedder。准备文档和查询问题时应使用相匹配的向量模型,才能让相似度比较有意义。

问答流程只需要“传入问题,拿到相关文档”。schema.Retriever 将这一需求定义为单个方法:

1
2
3
type Retriever interface {
GetRelevantDocuments(ctx context.Context, query string) ([]Document, error)
}

vectorstores.ToRetriever 保存 store、返回片段数量和查询选项。执行 GetRelevantDocuments 时,再调用 store 的 SimilaritySearch。因此,问答流程可以使用同一个文档查询接口,具体数据库的查询方式留在各个 store 中。

资料与回答

当用户问“报销需要哪些材料”时,RetrievalQA 先获得相关段落,再交给后续的回答流程。下面是 chains/retrieval_qa.go 中的关键调用,省略错误处理:

1
2
3
4
5
6
docs, err := c.Retriever.GetRelevantDocuments(ctx, query)

result, err := Call(ctx, c.CombineDocumentsChain, map[string]any{
"question": query,
"input_documents": docs,
}, options...)

question 是原问题,input_documents 是查到的资料。NewRetrievalQAFromLLM 默认选择 LoadStuffQA:它创建一个 StuffDocuments,将各片段的正文用空行连接,放入提示词的 context 变量,然后调用 LLMChain 生成回答。

因此,这条默认路径的实际过程很具体:查出报销条款,拼接条款正文,填入“根据这些资料回答问题”的模板,再调用模型。设置 ReturnSourceDocuments 后,还可以把查到的文档随回答返回,供应用展示来源;默认回答并不会自动逐条列出引用。

如果检索结果仍然太长,可以采用其他文档组合策略:

  • StuffDocuments:直接拼接资料,流程最短,但需要控制资料总长度。
  • MapReduceDocuments:分别处理文档,再汇总各份结果;处理文档的阶段通过 chains.Apply 支持限制并发数量。
  • RefineDocuments:先用第一份资料生成答案,再逐份读取资料修订答案,后一步依赖前一步的结果。

这些策略改变的是资料如何进入回答过程。检索器仍负责查资料,模型仍负责生成文本,各部分通过文档和链的接口协作。

智能体循环

制度文档可以回答报销规则,却无法告诉用户某张报销单的最新状态。要查询当前进度,需要访问业务系统;如果用户的问题不同,所需操作也不同,就需要在执行过程中选择工具。

决策与执行

agents.Agent 的 Plan 方法接收用户输入和已经执行的步骤,返回待执行的动作,或者最终答案。执行这些动作的是 agents.Executor。它实现了 chains.Chain,因此也可以从 chains.Call 进入。

agents/executor.go 中的循环主体如下:

1
2
3
4
5
6
7
8
steps := make([]schema.AgentStep, 0)
for i := 0; i < e.MaxIterations; i++ {
var finish map[string]any
steps, finish, err = e.doIteration(ctx, steps, nameToTool, inputs, options...)
if finish != nil || err != nil {
return finish, err
}
}

每次迭代先调用 Agent.Plan。如果返回最终答案,循环结束;如果返回动作,执行器按工具名称找到实现,调用工具,并将动作和结果追加到 steps。下一轮规划时,这些结果会再次交给 agent。

1
2
3
4
5
6
7
8
用户问题
|
v
Agent.Plan <--------------------------------+
| |
+--> 最终答案 --> 返回 |
| |
+--> 工具动作 --> Tool.Call --> 步骤记录-+

默认最大迭代次数为 5,达到上限仍未完成时返回 ErrNotFinished。一次规划可以返回多个动作,但当前执行器通过普通 for 循环逐个调用工具。同一轮返回多个工具请求,并不表示这些工具会并行执行。

工具协议

工具接口定义在 tools/tool.go:

1
2
3
4
5
type Tool interface {
Name() string
Description() string
Call(ctx context.Context, input string) (string, error)
}

名称和描述供模型了解工具用途,Call 执行实际操作。例如,应用可以提供一个报销状态查询工具,接收报销单号,调用内部 API,再返回进度文本。模型选择工具和输入,Go 程序负责实际查询。

模型输出如何变成动作,取决于 agent 实现。OneShotZeroAgent 从文本中解析 Action、Action Input 和 Final Answer;OpenAIFunctionsAgent 则把工具描述传给模型,从响应的工具调用字段中解析动作,再将工具结果整理为下一轮消息。

当前 OpenAIFunctionsAgent 会将 tools.Tool 包装成带有 __arg1 字符串参数的函数定义,再把这个值交给 Tool.Call。它沿用了工具接口的单字符串约定。自定义工具需要自行检查这个字符串的内容,并处理业务系统返回的结果。

执行器只从注册的工具中查找名称。找不到工具时,它会把错误提示记录为一次观察结果,供下一轮规划使用;工具执行返回错误时,当前流程则直接返回错误。工具的业务权限和参数规则仍在具体实现中处理。

流式输出与回调

一次问答可能同时经历检索、模型生成和工具查询。只看到最终答案,很难知道耗时发生在哪里;而等到全部生成完再显示,也会增加用户的等待感。LangChainGo 提供流式回调与执行事件,分别处理即时输出和过程记录。

响应片段

llms.CallOptions.StreamingFunc 接收生成过程中的字节片段。OpenAI 和 Anthropic 的内部 client 会据此开启流式请求,再各自解析服务返回的事件。

例如,Anthropic client 的 handleTextDelta 会先将文字追加到响应内容中,再调用回调。下面是回调部分:

1
2
3
4
5
6
if payload.StreamingFunc != nil {
err := payload.StreamingFunc(ctx, []byte(text))
if err != nil {
return response, fmt.Errorf("streaming func returned an error: %w", err)
}
}

应用可以把片段打印到终端,也可以转发给浏览器。回调处于响应处理路径上,耗时操作会影响读取速度;回调返回错误时,解析流程会收到这个错误。

执行事件

callbacks.Handler 提供模型开始与结束、链开始与结束、工具事件、检索事件、智能体动作等方法。具体组件在相应位置主动调用这些方法,应用可以记录输入输出、阶段耗时和中间动作。

例如,chains.Call 在执行链前后调用链回调,vectorstores.Retriever 在相似度查询前后调用检索回调。回调对象需要配置到对应组件上,外层链的 handler 不会自动为所有内层对象建立完整的事件记录。

调用过程还会向下传递 context.Context,模型的 HTTP 请求使用 http.NewRequestWithContext 创建。应用因此可以通过 context 控制请求期限。公共 httputil.DefaultClient 没有设置 Timeout,实际的调用期限需要由应用传入的 context 或自定义 HTTP client 确定。

模型缓存

对于相同输入重复生成的场景,可以在模型前加一层响应缓存。LangChainGo 的实现位于 llms/cache/cache.go,Cacher 本身也实现 llms.Model。

1
2
3
LLMChain --> Cacher --> 缓存命中 --> 返回响应
|
+--> 缓存未命中 --> 原始模型 --> 保存响应

调用进入 Cacher.GenerateContent 后,先根据本次选项生成 CallOptions,再将它与消息一起编码为 JSON,计算 SHA-256 作为缓存键。命中时直接返回响应,未命中时才调用底层模型并保存结果。上层仍然面对同一个模型接口,因此链的执行代码可以继续使用。

缓存保存的是一次模型调用的结果,对话记忆保存的是某个会话的消息。历史变化会改变传给模型的消息,也会改变缓存键。两者解决的问题不同,但都可以接入同一条流程。

当前缓存键由消息和本次调用选项组成,没有自动加入底层模型对象的全部构造配置。因此,如果多个模型共用一个缓存后端,应用需要安排缓存隔离,避免相同请求落到另一模型留下的结果上。

小结

LangChainGo 通过接口把一次模型请求扩展成完整应用:模型适配器处理不同服务的调用差异,提示词模板组织输入,链安排执行步骤,记忆组件把历史带入下一轮。文档问答在生成前增加资料检索,智能体则根据工具结果循环决定下一步。

沿着报销问答的过程看,这些组件各有具体作用:制度从文档变成可检索的片段,相关条款进入提示词,对话历史补充问题背景,业务工具提供最新进度,模型最后生成面向用户的回答。理解这条数据和控制流程,再回到目录与接口,就能看清各模块为什么这样划分,以及它们如何共同完成一次请求。