0%

LangChainGo 学习:快速入门

用 Go 调用大模型,最先遇到的问题通常不是发出一个 HTTP 请求,而是如何让模型、提示词、历史消息和外部资料可以逐步组合。每个模型服务的请求格式、鉴权方式和流式响应又不完全相同,如果业务代码直接绑定某一家服务,后续替换模型就要重新改一遍调用链。LangChainGo 是 LangChain 的 Go 实现,为这些常见需求提供了可组合的组件。

项目概览

LangChainGo 是一个用于开发大模型应用的 Go 库,可以集成到已有的命令行程序、HTTP 服务或后台任务中。模型生成回答,LangChainGo 则负责把模型调用与应用中的提示词、数据和工具连接起来。

核心功能

  • 模型接入:连接 OpenAI、Anthropic、Ollama 等服务,使用统一接口发送消息,并支持流式输出;具体能力取决于模型和对应实现。
  • 提示词管理:通过模板填入问题、背景资料等变量,复用固定的回答要求。
  • 流程组合:把提示词生成、模型调用和结果解析串成链,用于摘要、问答等任务。
  • 对话记忆:保存历史消息,在后续请求中提供上下文。
  • 文档检索:配合文本切分、向量生成和向量存储,查找与问题相关的资料,再交给模型回答。
  • 工具调用:让智能体根据问题选择工具,由程序执行查询、计算等操作,再把结果交回模型。

实现思路

这些功能通过 Go 接口连接。例如,模型接入层实现 llms.Model,上层代码通过它调用模型;不同服务的实现负责转换消息格式、设置鉴权信息和处理响应。文档检索也遵循同样的方式:上层通过 schema.Retriever 获取相关文档,具体实现负责查询数据源。

在此基础上,链把多个步骤组织成完整流程。以对话为例,先读取历史,再生成提示词并调用模型,最后保存本轮消息。提示词、模型和记忆各自负责一部分工作,链负责把它们连接起来。

使用时可以按需求逐步加入组件:单次问答只需模型调用,重复使用的提示词可以提取为模板,多轮对话再加入记忆。下面的组件地图展示了这些能力对应的包,以及它们在一次请求中的位置。

组件地图

一次请求可以先用下面的链路来理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[业务输入] ---------------------+
[memory 对话历史] --------------+--> [prompts 提示词模板]
[embeddings + vectorstores] ----+
|
v
[llms.Model]
|
v
[provider 适配器]
|
v
[模型服务]
|
v
[ContentResponse]
|
v
[输出解析 / 业务结果]
模块 解决的问题 关键接口或类型
llms 统一文本、聊天、多模态模型调用 llms.Model、MessageContent、ContentResponse
llms/anthropic、llms/openai 等 把统一请求转换成具体 provider 的 HTTP 请求 New、Option
prompts 用变量生成稳定、可复用的提示词 PromptTemplate、ChatPromptTemplate
chains 编排提示词、模型、解析器和记忆 Chain、LLMChain
memory 保存本轮之前的消息或摘要 ConversationBuffer、ChatMessageHistory
embeddings 把文本变成向量 Embedder
vectorstores 保存向量并按相似度查询文档 VectorStore、Retriever
agents / tools 让模型选择并调用外部工具 agent、tool 接口

后面的代码只实现一个小型的 Go 问答程序:默认模式使用提示词模板回答一个问题,-chat 模式使用对话链保留上下文。这样既能验证最短路径,也能看到组件如何逐层接入。

环境准备

LangChainGo 作为依赖安装在自己的 Go 项目中。先准备 Go 开发环境;本文所分析的源码在 go.mod 中声明了 Go 1.26.0,示例使用 Go 1.26.10 验证。

创建一个独立的示例项目,并安装本文使用的包:

1
2
3
4
5
6
7
go version
mkdir langchaingo-demo
cd langchaingo-demo
go mod init example.com/langchaingo-demo
go get github.com/tmc/langchaingo/llms/anthropic
go get github.com/tmc/langchaingo/prompts
go get github.com/tmc/langchaingo/chains

这里的 go.mod 属于 langchaingo-demo,Go 会下载 LangChainGo 及所需依赖。下文“完整示例”一节给出了全部代码,将它保存为项目目录下的 main.go 即可。后续命令都在 langchaingo-demo 中执行。

阅读实现时,可以另行克隆 LangChainGo 源码仓库,对照本文提到的文件查看。源码仓库用于阅读或修改库本身;上面的示例项目通过 Go Modules 引用这个库,两者有各自的 go.mod。

网关配置

本文使用 llms/anthropic,因为 Anthropic 兼容网关也可以通过它的 WithBaseURL 选项接入。示例支持两组变量:

1
2
3
export ANTHROPIC_AUTH_TOKEN='<你的网关令牌>'
export ANTHROPIC_BASE_URL='https://ai-api-gateway.app.baizhi.cloud/api/anthropic'
export ANTHROPIC_MODEL='deepseek-flash'

使用其他 Anthropic 兼容服务时,将地址、模型名和令牌替换为服务商提供的值。如果使用 Anthropic 官方 API,可以改为:

1
2
3
4
export ANTHROPIC_API_KEY='<你的 Anthropic API key>'
unset ANTHROPIC_AUTH_TOKEN
unset ANTHROPIC_BASE_URL
export ANTHROPIC_MODEL='claude-sonnet-4-6'

Anthropic provider 默认从 ANTHROPIC_API_KEY 读取密钥。本例同时支持 ANTHROPIC_AUTH_TOKEN,优先使用它的值,再通过 anthropic.WithToken 显式传给客户端。

基础调用

先看最小的模型调用。llms.Model 在 llms/llms.go 中定义了统一入口:

1
2
3
4
type Model interface {
GenerateContent(context.Context, []MessageContent, ...CallOption) (*ContentResponse, error)
Call(context.Context, string, ...CallOption) (string, error)
}
  • GenerateContent 接受一组带角色的消息,是完整能力的入口
  • Call 保留了早期的单字符串接口

业务代码依赖的是这个接口,所以把 Anthropic 换成 OpenAI、Ollama 或其他 provider 时,调用层不需要跟着更换类型。

模型初始化

示例中的模型初始化如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func newModel() (*anthropic.LLM, error) {
token := firstNonEmpty(
os.Getenv("ANTHROPIC_AUTH_TOKEN"),
os.Getenv("ANTHROPIC_API_KEY"),
)
if token == "" {
return nil, fmt.Errorf("missing API token")
}

opts := []anthropic.Option{anthropic.WithToken(token)}
if model := os.Getenv("ANTHROPIC_MODEL"); model != "" {
opts = append(opts, anthropic.WithModel(model))
}
if baseURL := os.Getenv("ANTHROPIC_BASE_URL"); baseURL != "" {
opts = append(opts, anthropic.WithBaseURL(baseURL))
}
return anthropic.New(opts...)
}

这段代码里的 Option 不是装饰性的配置集合。anthropic.New 会把选项应用到内部 client,client 保存 token、模型名、基础地址和 HTTP client;真正发送请求时再把基础地址和 /messages 拼起来。官方默认基础地址本身带有 /v1,兼容网关则可以把所需的前缀直接放进 ANTHROPIC_BASE_URL,同时设置 x-api-key、anthropic-version 等请求头。

这两个变量都用于提供访问凭据,但用途有所不同:

  • ANTHROPIC_API_KEY:保存从 Anthropic 控制台获取的 API 密钥,直接调用官方 API 时通常使用它。HTTP 请求可以通过 x-api-key 请求头携带这把密钥。
  • ANTHROPIC_AUTH_TOKEN:供支持此配置的客户端读取认证令牌。例如,Anthropic 官方 Python SDK 会将它放入 Authorization: Bearer <token> 请求头。使用 API key 时,无须再配置这个变量。

直接使用官方 API 时,按前面的配置设置 ANTHROPIC_API_KEY 即可。本文示例对 ANTHROPIC_AUTH_TOKEN 的读取属于额外适配;LangChainGo 当前仍通过 x-api-key 发送传入的凭据。

单次问答

最短的文本调用可以使用 llms.GenerateFromSinglePrompt:

1
2
3
4
5
6
answer, err := llms.GenerateFromSinglePrompt(
ctx,
llm,
"请用三句话解释 Go 的 context.Context 为什么重要。",
llms.WithMaxTokens(400),
)

这个辅助函数的实现很直白:它把字符串包装成一个 ChatMessageTypeHuman 消息,调用 GenerateContent,然后取第一项 ContentResponse.Choices 的 Content。因此它适合单一问题;需要多轮消息、图片或工具调用时,就应该直接使用 GenerateContent。

流式输出

模型响应可能需要几秒钟。如果等完整文本生成后再显示,命令行程序会显得像是卡住了。LangChainGo 把流式处理放进 CallOption:

1
2
3
4
5
6
answer, err := llms.GenerateFromSinglePrompt(ctx, llm, prompt,
llms.WithStreamingFunc(func(_ context.Context, chunk []byte) error {
fmt.Print(string(chunk))
return nil
}),
)

provider 收到这个回调后会打开流式响应,并在读取每个事件时调用它。回调返回错误即可中止读取。示例仍然接收最终的 answer,这是为了兼容某些不返回流式片段的服务;如果回调已经打印了片段,程序只需要在最后补一个换行即可。

提示词模板

把提示词直接写在函数里,问题一多就会出现字符串重复、变量拼错和格式不一致。prompts.PromptTemplate 解决的是“把固定说明和运行时输入分开”这个问题。

示例中的模板和渲染代码是:

1
2
3
4
5
6
7
8
9
10
template := prompts.NewPromptTemplate(
"你是一名 Go 教程作者。请用清晰、简洁的中文回答。\n"+
"主题:{{.topic}}\n问题:{{.question}}",
[]string{"topic", "question"},
)

prompt, err := template.Format(map[string]any{
"topic": "Go 基础",
"question": question,
})

NewPromptTemplate 默认使用 Go template 语法。源码中的 Format 先合并 partial variables 和本次传入的 values,再调用 RenderTemplate;渲染失败会直接返回错误。InputVariables 主要用于描述模板需要哪些输入,链在执行前会根据它做输入校验。

如果模型需要明确的 system、human、AI 角色,可以使用 ChatPromptTemplate 和对应的 message formatter:

1
2
3
4
5
6
7
8
9
10
chatPrompt := prompts.NewChatPromptTemplate([]prompts.MessageFormatter{
prompts.NewSystemMessagePromptTemplate(
"你是一名 Go 教程作者。",
nil,
),
prompts.NewHumanMessagePromptTemplate(
"请解释 {{.topic}}。",
[]string{"topic"},
),
})

调用 chatPrompt.FormatMessages 后,会得到一组带角色的消息:上面的第一条是 system 消息,第二条是 human 消息。模型可以据此区分系统指令和用户问题。

链式编排

当一次请求包含 准备输入、调用模型、解析输出、写入记忆 等步骤时,把这些步骤分散在业务函数中很快会变得难以维护。chains.Chain 约定了统一的输入、输出和记忆接口:

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

chains.NewLLMChain 是最常用的实现。它持有一个 prompts.FormatPrompter 和一个 llms.Model。调用时,源码依次完成三件事:

  • 用输入值格式化 prompt;
  • 调用 llms.GenerateFromSinglePrompt;
  • 使用 OutputParser 解析模型文本,并放入默认的 text 输出键。

执行链时,使用包级函数 chains.Call(ctx, chain, inputs)。它会先读取历史记录,再执行链并校验输入输出,最后保存本轮输入和结果。如果直接调用 chain.Call(ctx, inputs),就会跳过这些外围步骤,只执行链自身的逻辑。chains.Run 封装了 chains.Call,适合只有一个输入和一个字符串输出的场景。

这使得一个简单的模板和模型可以升级成可组合的链:先用一个链生成摘要,再把摘要交给下一个链;或者让检索链先拿到文档,再交给问答链生成答案。每个链仍然只关心自己的输入输出。

对话记忆

单次调用不会自动记住上一轮消息。把前一轮文本手动拼接到新 prompt 中,既容易漏消息,也会让业务代码承担存储和格式化责任。memory.NewConversationBuffer 提供了一个内存中的消息历史:

1
2
3
4
chatMemory := memory.NewConversationBuffer()
conversation := chains.NewConversation(llm, chatMemory)

answer, err := chains.Run(ctx, conversation, input, chains.WithMaxTokens(400))

NewConversation 使用仓库内置的对话模板,其中包含 history、input 和 AI 前缀。每次 chains.Run 进入公共调用流程时,ConversationBuffer.LoadMemoryVariables 读取历史并生成 history 字符串;模型返回后,SaveContext 再把当前 human 和 AI 消息写回 ChatMessageHistory。

示例的 -chat 模式正是这段流程的命令行版本:

1
go run . -chat

聊天模式同样设置了 chains.WithMaxTokens(400) 和流式回调。这样做一方面能尽快显示回答,另一方面兼容本文使用的网关:它在流式响应中可能只发送片段、把最终的 Content 留空。示例会把片段累积起来,并在这种情况下把最后一条空的 AI 消息替换为完整文本;消息的加载、执行和保存流程仍由 chains.Call 负责。

默认的 buffer 只存在于当前进程;需要跨进程保存时,可以把 schema.ChatMessageHistory 换成 SQLite、MongoDB、Cloud SQL 或 Zep 等实现,再通过 memory.WithChatHistory 注入。

这里有一个容易忽略的边界:memory 负责保存和提供历史,不负责判断历史是否正确,也不会自动解决上下文长度问题。长对话需要 WindowMemory 或 TokenBuffer,还需要根据模型的 token 限制选择裁剪或摘要策略。

文档问答

当问题涉及自己的文档时,单纯把整篇文档塞进 prompt 会遇到上下文长度、成本和相关性三个问题。LangChainGo 的文档问答路径把它拆成几个可以替换的环节。

首先用 textsplitter 把长文本分成带重叠的小块。RecursiveCharacter 默认按空行、换行、空格和字符逐级切分,并使用 4000 的 chunk size 和 200 的 overlap;这些数值可以通过 option 调整。重叠的作用是避免一句话刚好被切到两个块的边界。

接着由 embeddings.Embedder 为文本块和用户问题生成向量,再交给 vectorstores.VectorStore 保存和查询:

1
2
3
4
5
6
7
8
9
type Embedder interface {
EmbedDocuments(context.Context, []string) ([][]float32, error)
EmbedQuery(context.Context, string) ([]float32, error)
}

type VectorStore interface {
AddDocuments(context.Context, []schema.Document, ...Option) ([]string, error)
SimilaritySearch(context.Context, string, int, ...Option) ([]schema.Document, error)
}

文档存好后,下一步是根据问题查找资料。例如,用户问“报销需要哪些材料”,程序需要先从制度文档中找出与报销相关的段落。vectorstores.ToRetriever 为向量存储提供了一个查询入口,并指定每次返回多少个文档片段。调用它的 GetRelevantDocuments 方法,就能获得这些片段。

有了相关资料,还需要把资料和问题一起交给模型。chains.NewRetrievalQAFromLLM 将这两个步骤串起来:先查询文档,再把找到的片段放进提示词,让模型结合资料回答问题。在上面的例子中,模型收到的是用户的问题和检索到的报销规定。

源码中,查询资料由 Retriever 负责,整理资料并调用模型由 CombineDocumentsChain 负责。因此,更换向量数据库时,只要提供同样的文档查询接口,后面的回答流程就可以继续使用。

工具与智能体

有些问题不能只靠模型已有知识解决,例如查询实时天气、访问数据库或调用内部 API。这时可以把外部能力实现为 tools,再交给 agents 的执行器。模型负责选择工具和生成参数,执行器负责真正调用工具,并把工具结果作为下一轮消息送回模型。

这条路径比普通 chain 多了一层循环,因此要特别关注工具参数校验、最大迭代次数和超时。入门阶段先把模型、prompt、chain 和 memory 跑通,再引入 agent,排错范围会小很多。

完整示例

下面把模型配置、提示词模板和对话记忆放进同一个程序。将这段代码保存为 langchaingo-demo 目录下的 main.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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
package main

import (
"bufio"
"context"
"flag"
"fmt"
"log"
"os"
"strings"
"time"

"github.com/tmc/langchaingo/chains"
"github.com/tmc/langchaingo/llms"
"github.com/tmc/langchaingo/llms/anthropic"
"github.com/tmc/langchaingo/memory"
"github.com/tmc/langchaingo/prompts"
)

func main() {
chat := flag.Bool("chat", false, "start an interactive conversation")
question := flag.String("question", "请用三句话解释 Go 的 context.Context 为什么重要。", "question for the model")
flag.Parse()

llm, err := newModel()
if err != nil {
log.Fatal(err)
}

if *chat {
runChat(llm)
return
}

runPrompt(llm, *question)
}

func newModel() (*anthropic.LLM, error) {
token := firstNonEmpty(os.Getenv("ANTHROPIC_AUTH_TOKEN"), os.Getenv("ANTHROPIC_API_KEY"))
if token == "" {
return nil, fmt.Errorf("missing API token: set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY")
}

opts := []anthropic.Option{anthropic.WithToken(token)}
if model := os.Getenv("ANTHROPIC_MODEL"); model != "" {
opts = append(opts, anthropic.WithModel(model))
}
if baseURL := os.Getenv("ANTHROPIC_BASE_URL"); baseURL != "" {
opts = append(opts, anthropic.WithBaseURL(baseURL))
}

return anthropic.New(opts...)
}

func runPrompt(llm llms.Model, question string) {
template := prompts.NewPromptTemplate(
"你是一名 Go 教程作者。请用清晰、简洁的中文回答。\n主题:{{.topic}}\n问题:{{.question}}",
[]string{"topic", "question"},
)
prompt, err := template.Format(map[string]any{
"topic": "Go 基础",
"question": question,
})
if err != nil {
log.Fatal(err)
}

ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

answer, err := llms.GenerateFromSinglePrompt(ctx, llm, prompt,
llms.WithMaxTokens(400),
llms.WithStreamingFunc(func(_ context.Context, chunk []byte) error {
fmt.Print(string(chunk))
return nil
}),
)
if err != nil {
log.Fatal(err)
}
if answer != "" {
fmt.Println()
}
}

func runChat(llm llms.Model) {
chatMemory := memory.NewConversationBuffer()
conversation := chains.NewConversation(llm, chatMemory)

reader := bufio.NewScanner(os.Stdin)
fmt.Println("LangChainGo 对话示例,输入 quit 结束。")
for {
fmt.Print("你:")
if !reader.Scan() {
return
}
input := strings.TrimSpace(reader.Text())
if input == "quit" {
return
}
if input == "" {
continue
}

ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
var streamed strings.Builder
streamStarted := false
answer, err := chains.Run(ctx, conversation, input,
chains.WithMaxTokens(400),
chains.WithStreamingFunc(func(_ context.Context, chunk []byte) error {
if !streamStarted {
fmt.Print("AI:")
streamStarted = true
}
fmt.Print(string(chunk))
streamed.Write(chunk)
return nil
}),
)
if err != nil {
cancel()
fmt.Printf("调用失败:%v\n", err)
continue
}

if answer == "" {
answer = streamed.String()
}
if streamed.Len() > 0 {
fmt.Println()
} else {
fmt.Printf("AI:%s\n", answer)
}
if streamed.Len() > 0 {
if err := replaceEmptyLastAIMessage(ctx, chatMemory, answer); err != nil {
cancel()
fmt.Printf("保存对话历史失败:%v\n", err)
continue
}
}
cancel()
}
}

func replaceEmptyLastAIMessage(ctx context.Context, chatMemory *memory.ConversationBuffer, answer string) error {
messages, err := chatMemory.ChatHistory.Messages(ctx)
if err != nil || len(messages) == 0 {
return err
}
last, ok := messages[len(messages)-1].(llms.AIChatMessage)
if !ok || last.Content != "" {
return nil
}
messages[len(messages)-1] = llms.AIChatMessage{Content: answer}
return chatMemory.ChatHistory.SetMessages(ctx, messages)
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

示例运行

默认模式的完整命令:

1
go run .

终端会看到类似下面的流式输出,具体措辞由模型决定:

1
`context.Context` 提供统一机制,在调用链和多个 goroutine 之间传播取消信号、超时和截止时间。

也可以指定自己的问题:

1
2
go run . \
-question '请给出一个适合初学者的 Go 接口示例,并说明隐式实现。'

示例使用流式回调,终端会边生成边打印内容。没有配置 token 时,程序会在初始化阶段给出明确错误;网关返回非 2xx 状态时,Anthropic client 会读取响应中的错误消息并向上包装,便于定位鉴权、模型名或网关地址问题。

在不访问模型的情况下,也可以先验证源码和依赖:

1
go test .

小结

本文从一个 Go 问答程序出发,介绍了模型配置、单次问答和流式输出,再通过提示词模板、链和对话记忆,实现了能够保留上下文的聊天示例。结合源码,我们也了解了文档问答中“先查资料、再生成回答”的过程,以及工具和智能体的作用。

这些功能通过接口连接,可以按应用需要逐步组合。理解了模型、提示词、链和记忆之间的关系,就有了继续开发文档问答或工具调用应用的基础。