利用大模型构建答疑机器人

31 阅读16分钟

本文用来记录学习 agent 开发过程中了解到的相关知识

项目背景

你在一家初创公司工作,随着新员工的加入,频繁的答疑给你这个老员工带来了不少时间成本。于是你决定利用大模型做一个答疑机器人,由此提升大家的工作效率以及答案的准确性。

基础对话调用

下面我们先来构造一个简单的对话。创建一个“员工小助手”,向他提问公司相关的问题,如:我们公司项目管理应该用什么工具?

from openai import OpenAI
import os
client = OpenAI(
    api_key=os.getenv("API_KEY"),
    # 这里改为你使用的目标服务
    base_url="https://xxxx"
)
def get_response(prompt):
    response = client.chat.completions.create(
        # 这里改为你使用的模型
        model="你使用的模型",
        messages=[
            # system message 用于设置大模型的角色和任务
            {"role": "system", "content": "你负责公司内相关问题的答疑,你的名字叫员工小助手,你要回答同事们的问题。"},
            # user message 用于输入用户的问题
            {"role": "user", "content": prompt}
        ]
    )
    return response.choices[0].message.content
response = get_response("我们公司项目管理应该用什么工具")
print(response)

以下是模型给出的答案:

image.png

以上只是实现了一轮对话。但现实场景中,往往用户会进行多轮提问。如:「我们公司项目管理应该用什么工具?」「项目管理工具的地址是什么?」「我现在没有权限打开,如何申请权限?」。

由于 LLM 的“天花板”之一是没有记忆:上下文窗口一满就"失忆",跨会话什么都没留下。那么让大模型能参考历史信息,根据上下文的关联给出连贯、准确的答复就尤为重要了。

下面我们继续探索,在多轮对话中,如何保证上下文的连贯的呢?

多轮对话

多轮对话的工作原理

在 OpenAI SDK 中,实现多轮对话的关键在 message。message 参数是一个列表,是对话完整的历史记录。它的每项包含:

  1. role:消息的角色。可以是 system(系统指令)、user(用户输入)、assistant(模型回复);

  2. content:消息的内容。

LLM 会根据 message 列表中的消息来生成回复,所以要将之前的对话都保存在这个列表中。

def multi_turn_chat():
    # 初始化对话历史,系统提示词
    conversation_history = [
        {"role": "system", "content": "你负责公司相关问题的答疑,你的名字叫员工小助手,你要回答同事们的问题。"}
    ]
    # 模拟多轮对话
    user_questions = [
        "我们公司项目管理应该用什么工具?",
        "工具的地址是什么?",
        "如何申请权限?"
    ]
    for question in user_questions:
        print(f"👤 用户:{question}")
        
        # 将用户问题添加到对话历史
        conversation_history.append({"role": "user", "content": question})
        
        # 调用大模型,传入完整的对话历史
        response = client.chat.completions.create(
            model="你使用的模型",
            messages=conversation_history  # 包含所有历史消息
        )
        
        # 获取模型回复
        assistant_message = response.choices[0].message.content
        print(f"🤖 小助手:{assistant_message}\n")
        
        # 将模型回复也添加到对话历史,以便下一轮对话使用
        conversation_history.append({"role": "assistant", "content": assistant_message})
        
multi_turn_chat() 

运行结果:

image 6.png

通过运行上面的结果,我们可以看到“工具”是指之前提到的“项目管理工具”,有了历史对话上下文,不需要用户特别强调上下文,模型就可以针对性的回答相关问题了。

流式输出

通过观察上述代码的运行情况,我们发现结果是等了好久之后一股脑输出的。而平时使用其他 agent(如豆包),它的输出是类似于打字的效果,一边思考一边输出,大大提升了用户体验,这种输出方式我们称之为流式输出。下面来看下在代码层面,如何实现流式输出呢?

实现非常简单,只需增加 stream=True 即可:

def get_stream_response(user_prompt,system_prompt):
    response = client.chat.completions.create(
        model="你使用的模型",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ],
        stream=True
    )
    for chunk in response:
        yield chunk.choices[0].delta.content

response = get_stream_response(user_prompt="我们公司项目管理应该用什么工具",system_prompt="你负责公司的答疑,你的名字叫员工小助手,你要回答同事们的问题。")
for chunk in response:
    print(chunk, end="")

20260920172839_rec_.gif

实践思考

通过上面多次运行的结果我们可以发现:

  1. 即使每次提问的问题一模一样,但模型给出的答案略有不同。

  2. 模型给出项目管理工具的答案是业界广泛使用的项目管理工具,例如 Jira、Trello 等。并未根据我们公司的情况给出答案。(实际可以通过相关的文档资料搜索给出正确答案)

那为什么会产生以上现象呢?下面我们通过一点点了解大模型的工作原理来揭开答案。

大模型是如何运作的?

大模型的文本生成工作流

大模型的文本生成流程可以分为文本分词、Token 向量化、大模型推理、解码与自回归、输出文本五个阶段:

a9c8b57986484e05bc8b51b0da0c7f6b.jpeg~tplv-xobrcjvdq7-image.jpeg

第一阶段:文本分词

大模型无法直接理解人类的文字。因此,第一步需要将我们输入的文字“Tom is a”转换成计算机能够处理的数字格式。这个过程被称为 Tokenization(分词)。

Token 是分词器(Tokenizer)把文本编码后得到的基本单元,每个 Token 对应词表中的整数 ID。

第二阶段:Token 向量化

虽然我们得到了数字 ID 序列,但这些 ID 本身的数值大小并没有实际意义。为了让模型理解 token 的真实含义,我们需要将这些离散的 ID 转换成包含丰富语义信息的数学表示——向量(Vector)。

这个转换通过一个称为 Embedding 矩阵的巨大表格完成。简单来说就是用 token ID 在这个大表格里查表,取出对应的那一整行向量。每个 token 的向量都包含了其在多维语义空间的坐标。

此外,语言的顺序至关重要("我帮你"和"你帮我"截然不同)。但基础的 Transformer 模型本身并不直接处理时序信息。因此,还需要额外给每个 token 向量添加位置信息(Positional Encoding),让模型能够区分出“我”是第一个词,“你”是第三个词,以此类推。

第三阶段:大模型推理

现在,携带了语义和位置信息的向量序列被送入 Transformer 模型的解码器层(Decoder Blocks)进行计算。这个过程被称为前向计算(Forward Pass)。

向量序列会逐层穿过数十个甚至上百个结构相似的解码器层,在因果自注意力机制(Causal Self-Attention)和前馈神经网络(Feed-Forward Network)的作用下,文本向量中的信息被一次一次压缩和提取。我们最终会得到最最后一个token(也就是"a")所对应的隐藏状态向量(HidderState)。这个向量可以被认为是模型在阅读了"Tom is a"之后,对接下来可能出现的内容的一个高度浓缩的"思考总结"。最后,这个"思考总结"向量会通过一个线性投影层,映射到整个

词汇表的维度,得到一个庞大的分数向量(Logits)。

第四阶段:解码与自回归

在得到 logits 之后,大模型还需要经过 softmax 函数计算,将这些logits重重新映射为一种概率分布 P(next_token | context),表示每个token被选中的概率。最后,大模型会根据一个解码策略来决定最终输出哪个token。解码策略主要分为两类:

  • 近似确定性解码:如贪心解码(Greedy Decoding),每次选概率最高的 token,或 Beam Search 保留多个候选路径。

  • 随机采样解码:如Top-p (Nucleus Sampling)、Top-kSampling,从高概率的候选集合中随机抽取。

很多在线服务默认使用随机采样解码,因此即使输入完全相同,也可能出现:回答略有不同“的现象。

通过调整 temperature,你可以改变 softmax 输出概率分布的“尖锐程度”。temperature 越低,token之间的概率差异越大,概率分布越集中于少数的高概率 token,模型输出越确定。temperature越高,输出概率分布越平坦,模型的输出多样性越高。配合 top_p(控制参与采样的候选集合范围)等参数,你可以在模型输出的多样性与稳定性之间做权衡。

一旦选定了下一个 token(比如模型选择了"cat"),这个新生成的token就会被追加(append)到原始输入序列的末尾,形成新的输入"Tom is a cat"。然后模型会基于这个新序列,重复第三和第四阶段,继续预测下一个token。这个"文字接龙"的过程称为自回归生主成(Autoregressive Generation)。模型的自回归循环会持续进行,直到满足某个停止条件:

  • 生成了特殊的终止符(End-of-Sequence, EOS token)。

  • 达到了预设的最大生成长度限制。

  • 生成了用户指定的停用词序列。

第五阶段:输出文本

最后,系统会将整个生成过程中的token ID序列转换回人类可读的字符串,并呈现给我们。

我们经常看到的流式输出(Streaming)效果,其实是服务端每生成一个或几个token,就立刻将其解码并增量地发送到你的界面上。由于token可能是子词片段,所以在流式显示时,有时会看到一个词被分成几部分输出,或者空格的出现时机看起来有些奇怪,这都是正常现象。

影响大模型内容生成的随机性参数

假设在一个对话场景中,用户提问为:“在这篇文章中中,可以学习到什么?”。假设候选的 token 合集是:“RAG”、“提示词”、“模型”、“写作”、“画画”。大模型会从这几个候选 token 中选择一个作为结果输出(next-token)。

在这个过程中,有两个重要参数会影响大模型的输出:temperature、top_p,它们用来控制大模型生成内容的随机性和多样性。下面介绍这两个参数的工作原理和使用方式。

temperature:调整后选 token 集合的概率分布

在大模型生成下一个词(next-token)之前,它会先为候选 token 计算一个初始概率分布。这个分布表示每个候选 token 作为 next-token 的概率。temperature 是一个调节器,它通过改变候选 token 的概率分布,影响大模型的内容生成。通过调节这个参数,你可以灵活的控制生成文本的多样性和创造性。

image.png 由上图可知,温度从低到高(0.1->0.7->1.2),概率分布从陡峭趋于平滑,候选 Token"RAG" 从出现的概率从 0.8->0.6->0.6->0.3,虽然依然是出现概率最高的,但是已经和其它的候选 Token 概率接近了,最终输出也会从相泪对固定到逐渐多样化。

针对不同使用场景,可参考以下建议设置temperature参数:

  • 明确答案(如生成代码):调低温度。

  • 创意多样(如广告文案):调高温度。

  • 无特殊需求:使用默认温度(通常为中温度范围)。

需要注意的是,当 temperature = 0 时,虽然会最大限度降低随机性,但无法保证每次输出完全一致。如果想深入了解,可查阅temperature的底层算法实现。

下面体验 temperature 的效果,通过调整 temperature 的值,对同一问题提问多次,观察回答问题的波动情况:

import time

def get_doubao_stream_response(user_prompt, system_prompt, temperature, top_p):
    response = client.chat.completions.create(
        model="你使用的模型",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ],
        temperature=temperature,
        top_p=top_p,
        stream=True
    )
    
    for chunk in response:
        yield chunk.choices[0].delta.content

# temperature
def print_doubao_stream_response(user_prompt, system_prompt, temperature=0.7, top_p=0.8, iterations=10):
    for i in range(iterations):
        print(f"输出 {i + 1} : ", end="")
        ## 防止限流,添加延迟
        time.sleep(0.5)
        response = get_doubao_stream_response(user_prompt, system_prompt, temperature, top_p)
        output_content = ''
        for chunk in response:
            output_content += chunk
        print(output_content)

# 设置temperature=0
print_doubao_stream_response(user_prompt="马也可以叫做", system_prompt="请帮我续写内容,字数要求是4个汉字以内。", temperature=0)

运行结果:

image.png

# 设置temperature=1.9
print_doubao_stream_response(user_prompt="马也可以叫做", system_prompt="请帮我续写内容,字数要求是4个汉字以内。", temperature=1.9)

运行结果:

image.png

实验中可以看出,温度越高,输出的内容更具随机性、多样性。

top_p:控制候选 token 集合的采样范围

top_p 是一种筛选机制,用于从候选 token 集合中选出符合特定条件的小集合。具体方法是:按概率从高到低排序,选取累计概率达到设定阈值的 token 组成的新的候选集合,从而缩小选择范围。

下图展示了不同top_p值对候选Token集合的采样效果。

image.png

图示中蓝色部分表示累计概率达到 top_p 阈值(如 0.5 或 0.8)的Token,它们组成新的候选集合;灰色部分则是未被选中的Token。

当top_p=0.5时,模型优先选择最高概率的 Token,即"RAG";而当 top_p=0.8时,模型会在"RAG"、"提示词"、"模型"这三个Token中随机选择一个生成输出。

由此可见,top_p值对大模型生成内容的影响可总结为:

  • 值越大:候选范围越广,内容更多样化,适合创意写作、诗歌生成等场景。

  • 值越小:候选范围越窄,输出更稳定,适合新闻初稿、代码生成等需要明确答案的场景。

  • 极小值(如0.0001):理论上模型只选择概率最高的Token,输出出非常稳定。但实际上,由于分布式系统、模型输出的额外调整等因素可能引入的微小随机性,仍无法保证每次输出完全一致。

下面体验top_p的效果。通过调整top_p值,对同一问题提问多次,观察回答内容的波动情况。

# 设置top_p=0.001
print_doubao_stream_response(user_prompt="为一解答公司内相关问题的机器人取名,可以是", system_prompt="请帮我取名,字数要求是4个汉字以内,只输出名字。", top_p=0.001)

运行结果:

image.png

top_p=0.8:

# 设置top_p=0.8
print_doubao_stream_response(user_prompt="为一解答公司内相关问题的机器人取名,可以是",system_prompt="请帮我取名,字数要求是4个汉字以内,只输出名字。", top_p=0.8)

运行结果:

image.png

让大模型能回答私域知识

大模型知识完全来自训练数据(公开互联网),不包含任何公司内部信息。直接问"我们该用什么项目管理工具",模型只给出通用建议(Jira/Trello),而不知道公司内在用什么。

如果在提示词中喂入知识,即直接添加到 System Prompt 中,作为背景知识提供给它,会发现一旦输入内容超过模型的最大限制,就会导致错误。这是由于上下文窗口是有限的,这就引出了一个核心问题:需要对放入上下文窗口的内容进行筛选和管理。

解决之道:上下文工程

简单粗暴的将信息塞进上下文,除了会超出窗口限制外,还会带来一系列隐形问题:

  1. 效率低:上下文越长,大模型处理所需的时间就越长,导致用户等待时间增加。

  2. 成本高:大模型是按输入输出文本量计费的,冗长的上下文意味着更高的成本。

  3. 信息干扰:如果上下文包含了大量与当前问题无关的信息,就像在开卷考试给考生一本其他科目的教科书,反而会干扰模型的判断,导致回答质量下降。

所以,关键不在于喂给模型多少知识,而在于喂的多准。

如何在正确的时间,将最相关、最精准的知识,动态地加载到到大模型有限的上下文窗口中?这门系统性地设计、构建和优化上下文的实践,就是上下文工程(Context Engineering)。

上下文工程的核心技术

  • RAG(检索增强生成):从外部知识库(如公司文档)中检索信息,为模型提供精准的回答依据。

  • Prompt(提示词工程):通过精心设计的指令,精确地引导模型型的思考方式和输出格式。

  • Tool (工具使用):赋予模型调用外部工具(如计算器、搜索引擎、API)的能力,以获取实时信息或

执行特定任务。

  • Memory(记忆机制):为模型建立长短期记忆,使其能够在连续对话中理解历史上下文。

技术方案:RAG

RAG(Retrieval-Augmented Generation, 检索增强生成)就是实现上下文工程的强大技术方案。它的核心思想是:

用户提问时,不再将全部知识库硬塞给大模型,而是先自动检索出与问题最相关的私有知识片段,然后将这些精准的片段与用户问题合并后,一同传给大模型,从而生成最终的答案。这样既避免了提示词过长的问题,又能确保大模型获得相关的背景信息。

构建一个 RAG 应用通常会分为两个阶段

第一阶段:建立索引

image.png

建立索引是为了将私有知识或文档片段转换为可以高效检索的形式。通过将文件内容分割并转化为多维向量(使用 embedding 模型),并结合向量存储保留文本的语义信息,方便进行相似度计算。向量化使得模型能够高效检索和匹配相关内容,特别在处理大规模知识库时,显著提高了查询的准确性和响应速度。

这些向量经过 Embedding 模型处理后不仅很好的捕捉文本内容的语义信息,而且由于语义已经向量化、标准化,便于之后与检索语义向量进行相似度计算。

第二阶段:检索生成

image.png 检索生成是根据用户的提问,从索引中检索相关的文档片段,这些片段会与提问一起输入到大模型生成最终的回答,这样大模型就能回答私有知识问题了。

总而言之,基于 RAG 结构的应用,既避免了将整个参考文档作为背景信息输入而导致的各种问题,又通过检索提取出了与问题最相关的部分,从而提高了大模型输出的准确性与相关性。

总结

通过以上内容,可以了解到如何利用大模型相关 API 实现一个简单的多轮对话机器人,同时也了解到了大模型的工作原理以及可以利用大模型的特点及已沉淀下来的知识库,让大模型可以回答私域知识。

参考文献