用大模型构建答疑机器人:从 API 调用到上下文工程

新员工入职,老员工被同一批问题反复打断。“报销走什么流程”“项目管理该用哪个工具”“账号怎么申请”。答案都躺在公司文档库里,但没人愿意翻几十页 PDF。很自然地会想:拿大模型做一个答疑机器人不就行了。

一开始确实很顺。几十行代码,一个 API Key,机器人就能流畅作答。但真往下做,会连着撞三堵墙:

  1. 同一个问题问两遍,答案不一样,有时候还互相矛盾。
  2. 稍微问点公司内部的事,它就开始一本正经地编。
  3. 想把手册、规范文档全塞进提示词,直接超了长度限制。

这三堵墙不是各自独立的 bug,它们指向同一个东西。下面按撞墙的顺序走一遍,从调通第一次 API 到最终绕不开的那个概念。

一、先把一次调用跑通

大模型没有统一入口。每家厂商都有自己的 HTTP 接口,但 OpenAI 的 SDK 事实上成了行业通用协议:只要服务端提供兼容模式,换个 base_url 就能用同一套代码。DashScope(阿里云百炼)提供的就是这样的兼容端点,所以调通义千问不需要额外学一套 SDK。

第一件事是别把 API Key 写进代码。硬编码的 Key 有三个麻烦:分享代码等于泄露,轮换密钥时要满仓库找,Git 历史里还删不干净。放环境变量,配一个加载函数:

# ch01/util/env.py —— 最小实现:加载密钥 + 创建客户端,后续各节复用
import json
import os
from openai import OpenAI


def load_keys(path="config/Key.json"):
    if not os.path.exists(path):
        key = input("未找到密钥文件,请粘贴你的 API Key:")
        with open(path, "w") as f:
            json.dump({"DASHSCOPE_API_KEY": key}, f, indent=4)
    with open(path) as f:
        keys = json.load(f)

    os.environ["DASHSCOPE_API_KEY"] = keys["DASHSCOPE_API_KEY"].strip()
    os.environ["LLM_BASE_URL_QWEN"] = keys.get(
        "LLM_BASE_URL_QWEN",
        "https://dashscope.aliyuncs.com/compatible-mode/v1",
    )


def qwen_client():
    return OpenAI(
        api_key=os.environ["DASHSCOPE_API_KEY"],
        base_url=os.environ["LLM_BASE_URL_QWEN"],
    )

base_url 写进配置文件而不是写死,是为了留个换端点、换代理的口子。真要换模型服务商,改一行配置就行,代码不动。

单轮对话就这么点东西:

# ch01/test01_chat.py
import os
from openai import OpenAI
from util.load_env import load_keys

load_keys()

client = OpenAI(
    api_key=os.environ["DASHSCOPE_API_KEY"],
    base_url=os.environ["LLM_BASE_URL_QWEN"],
    # base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)


def get_chat_response(prompt):
    response = client.chat.completions.create(
        model='qwen-max',
        messages=[
            {"role": "system", "content": "你负责教育内容开发公司的答疑,你的名字叫公司小蜜,你要回答同事们的问题。"},
            {"role": "user", "content": prompt},
        ],
    )
    return response.choices[0].message.content


if __name__ == "__main__":
    r = get_chat_response('我们公司项目管理应该用什么工具')
    print(r)

messages 是个列表,system 定角色和行为边界,user 是实际问题。返回值里 choices 是列表,取 choices[0].message.content 才是文本。

现象真相
拿不到回复内容,报错信息又出现在很深的一层choices 是复数,SDK 返回的是列表不是单对象,眼睛划过很容易写错。接 SDK 最省事的做法是先 print(response) 把原始结构看一遍,别靠记忆猜字段名

跑一下,它会给你一段关于 Jira、Trello、Asana 的建议。看起来挺专业。但这里已经埋了第一个隐患——它给的全是通用工具,跟"你们公司"没什么关系。这个回头再说。

二、多轮对话:API 自己不长记忆

第二个问题来得很快。用户问完"用什么项目管理工具",紧接着问"那我怎么申请账号呢"。如果机器人不知道上文,它会去讲一套通用的账号申请流程,跟你刚才问的工具完全脱钩。

根因是:Chat Completions 接口是无状态的。服务端不保存你的会话,每次调用都是干净的一张白纸。所谓"记忆",全是客户端每轮自己把完整历史重新发一遍。

# ch01/test02_multi_turn_chat.py
import sys

sys.path.insert(0, '../util')
from util import tools


def multi_turn_chat():
    user_questions = [
        "我们公司项目管理应该用什么工具?",
        "那我怎么申请这个工具的账号呢?",
        "申请一般需要多久能批下来?",
    ]

    conversation_history = [
        {'role': 'system', 'content': '你负责教育内容开发公司的答疑,你的名字叫公司小蜜,你要回答同事们的问题。'}
    ]
    client = tools.qwen_client()

    for question in user_questions:
        print('我:', question)
        conversation_history.append({'role': 'user', 'content': question})

        response = client.chat.completions.create(
            model="qwen-max",
            messages=conversation_history,
        )
        response = response.choices[0].message.content  # 注意是 choices,复数

        print(f"🤖 小蜜:{response}\n")

        conversation_history.append({'role': 'assistant', 'content': response})

循环里那两行 append 才是重点。用户问题要 append,模型自己的回复也必须 append。少了第二行,下一轮模型就"忘了自己刚说过什么",同一个追问它会给出互相矛盾的说法。这不是它记性差,是你的历史里根本没有那轮对话。

messages 里三种角色的分工值得记一下:

role作用谁来写
system设定角色、任务边界、回答风格,整个会话持续有效开发者
user用户的问题用户
assistant模型的回答,回填历史后成为下一轮的上下文模型(但由你负责存下来)

代价也跟着来了。历史越攒越长,每轮请求都在把前文重发一遍:token 消耗线性上涨,响应变慢,而且迟早会撑爆上下文窗口。真上生产,历史得落库,还得配截断或者摘要策略——只保留最近 N 轮,或者把久远的对话压成一段摘要。

三、流式输出:别让用户盯着空白等 20 秒

默认的调用方式要等模型把整段答案生成完才一次性返回。长回答等十几二十秒很正常,用户面对的是一个转圈的空界面。体感上这比答案差还难受。

流式输出把这件事拆开:模型每生成一小段就立刻推给客户端,屏幕上像打字一样逐步浮现。

# ch01/Test03_stream.py
import sys

sys.path.insert(0, '../util')
from util import tools

tools.load_key()
client = tools.qwen_client()


def stream_response(sys_prompt, user_prompt):
    '''流式响应
    :param sys_prompt:  系统提示词
    :param user_prompt: 用户提示词
    :return: 生成器
    '''
    response = client.chat.completions.create(
        model='qwen-max',
        messages=[
            {'role': 'system', 'content': sys_prompt},
            {'role': 'user', 'content': user_prompt},
        ],
        stream=True,
    )
    for chunk in response:
        yield chunk.choices[0].delta.content


if __name__ == "__main__":
    print('流式处理:')
    response = stream_response(
        sys_prompt="你负责教育内容开发公司的答疑,"
                   "你的名字叫公司小蜜,你要回答同事们的问题。",
        user_prompt="我们公司项目管理应该用什么工具")
    for chunk in response:
        print(chunk, end='')
    print()

加个 stream=True,返回的东西就从"一个完整响应"变成了"一个可迭代的响应对象"。字段也跟着变了:非流式是 choices[0].message.content,流式是 choices[0].delta.contentdelta 含义是增量。

这里有个必须处理的细节:

现象真相
拼出来的回答开头莫名多出一个 None流式响应的首帧只带 role、不带 contentdelta.contentNonecontent += chunk 会把它拼成字符串 "None",得先判空:if chunk is not None

还有一个常见误解:流式输出只是换了展示方式,模型的推理过程和最终答案质量完全一样。它快的是感知,不是计算。

四、掀开盖子:模型是怎么吐出下一句话的

到这里,第一个和第二个问题解决了,第三个问题(答不准)还没碰。要理解它,先得知道模型内部在干什么。

拿输入 ACP is a very 举例。整个生成流程可以拆成五步:

1. 文本分词(Tokenization)

计算机看不懂文字,第一步先把文本转成数字。分词器把句子切成若干 token,每个 token 对应词表里的一个整数 ID。

有个容易误解的点:token 不等于"词"。它通常是子词片段或字符片段,英文单词可能被拆成词根加后缀,中文可能按字或常见词组切,空格和标点也可能各自成为 token。所以 token 数跟"字数"不是一回事。计费按 token 算,中文场景下大致在字符数量级,具体看分词器。

2. Token 向量化

拿到 ID 序列还不够用。ID 的数值本身没有语义——ID 500 不代表它比 ID 50 更重要或更相关。得把每个 ID 映射成一个高维向量,这件事靠一张巨大的 Embedding 矩阵查表完成。

还有个坑:Transformer 本身不处理顺序信息。我帮你你帮我 用同一批 token,语义却相反。所以每个 token 向量还得额外叠加位置编码(Positional Encoding),让模型知道谁在前谁在后。

3. 前向计算

携带语义和位置信息的向量序列送进解码器层,逐层穿过几十甚至上百层。每一层里,因果自注意力(Causal Self-Attention)负责让每个 token 参考它前面的上下文,前馈网络(Feed-Forward Network)负责做非线性变换。走完全部层之后,最后一个 token 对应的隐藏状态向量,就是模型读完这句话之后的"思考总结"。这个向量再经过一次线性投影,映射到整个词表的维度,得到一组分数(logits)。

4. 解码与自回归

logits 经过 softmax 变成概率分布,表示每个 token 作为下一个词的概率。然后按解码策略挑一个出来:

  • 近似确定性解码:贪心解码每步取概率最高的那个,或者 Beam Search 同时保留多条候选路径。
  • 随机采样解码:Top-p、Top-k 这类,从高概率候选集合里随机抽。

大多数在线服务默认走随机采样,这就是第一个问题的根源——同样的输入,两次输出不一样,不是 bug,是设计

选定一个 token 后,它被追加到原输入末尾,形成 ACP is a very informative,然后重复第 3、4 步继续预测下一个。这个"文字接龙"的循环叫自回归生成(Autoregressive Generation)。

循环什么时候停?三种条件:生成终止符(EOS token)、达到最大生成长度、命中用户指定的停用词。各家模型的终止符并不相同,混用模型时要留意:

模型系列终止符
GPT 系`<
Qwen3`<
LLaMA / Mistral</s>
DeepSeek V3.2<|end▁of▁sentence|>

5. 输出文本

最后把整串 token ID 解码回人类可读的字符串。流式显示时偶尔会看到一个词被切成几截冒出来,或者空格出现的位置有点怪,原因就在这里——服务端是按 token 粒度往外推的,而 token 本身就是子词片段。

这五步走完,第一个问题的解释也就落地了:随机性不在于模型"想不想稳定",而在于第 4 步那个采样动作。想让它稳,就得动采样参数。

五、两个旋钮:temperature 与 top_p

temperature 调的是概率分布的陡峭程度

模型在选下一个 token 之前,先给出一个概率分布。temperature 是作用于这个分布的调节器:温度低,高低概率之间的差距被放大,分布变得陡峭,高概率 token 几乎稳赢;温度高,差距被抹平,分布变平缓,冷门 token 也有出头机会。

用一组预设候选来观察会更直观。问题是"在大模型课程里能学到什么",候选 token 是 RAG / 提示词 / 模型 / 写作 / 画画

temperatureRAG 的选中概率分布形态输出表现
0.1(低)0.8陡峭几乎每次都选 RAG
0.7(默认)0.6中等多数选 RAG,偶尔换
1.2(高)0.3平坦五个候选都有一拼,结果发散

跑十次对比一下很直观:

# ch01/Test04_temperature.py
import sys

sys.path.insert(0, '../util')
from util import tools

tools.load_key()


def stream_response(user_prompt, system_prompt, temperature=0.7, top_p=0.8):
    client = tools.qwen_client()
    response = client.chat.completions.create(
        model="qwen-max",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        stream=True,
        temperature=temperature,
        top_p=top_p,
    )

    for chunk in response:
        yield chunk.choices[0].delta.content


def print_stream_response(system_prompt, user_prompt, temperature=0.7, top_p=0.8, iterations=10):
    for i in range(iterations):
        print(f'输出{i+1}: ', end='')
        response = stream_response(
            system_prompt=system_prompt,
            user_prompt=user_prompt,
            temperature=temperature,
            top_p=top_p,
        )
        content = ''
        for chunk in response:
            content += chunk
        print(content)


if __name__ == "__main__":
    print_stream_response(
        user_prompt="为一款智能游戏手机取名,可以是",
        system_prompt="请帮我取名,字数要求是4个汉字以内。",
        temperature=0.7,
        top_p=0.7,
    )

temperature 改成 0 跑十遍,名字高度趋同;改成 1.9,十遍能给你十个花样。任务本身没有正确答案,这种差异正好反映了创造力的多少。

(Qwen-Max 的 temperature 取值区间是 [0, 2),默认 0.7。)

top_p 调的是候选集合的大小

top_p 换了个思路:先把候选按概率从高到低排好,从头累加,累计概率刚够到阈值的这一批 token 组成本次的候选集合,其他的直接出局,然后在集合内按原概率比例归一化后采样。

top_p = 0.5,累计到 0.5 时只有 RAG 进来,候选集合就一个元素,输出必然稳定。设 top_p = 0.8RAG提示词模型 三个都进集合,模型在三者之间随机挑。设 top_p = 0.001,理论上永远是概率最高的那个。

值越大候选范围越宽,输出越多样;值越小输出越稳。

该用哪一档

任务类型temperaturetop_p理由
生成代码、结构化信息抽取、分类接近 0(如 0.1)0.3 左右要的是稳定一致,重复跑结果得能对上
客服问答、技术文档0.3 ~ 0.50.7 左右需要准确,但不至于机械
通用对话0.7(默认)0.8(默认)均衡
广告文案、起名、创意写作0.8 ~ 1.20.9 左右要的就是发散

两条使用纪律:

别同时调两个。 temperature 和 top_p 都作用于采样,同时动会让输出行为难以归因,你说不清效果是哪个参数带来的。挑一个调,观察,再微调,每次幅度控制在 ±0.2。

别指望 temperature=0 就完全一致。 即使温度拉到 0、top_p 压到 0.0001、seed 固定,同一个问题的结果仍可能有细微差异。原因在工程层面:模型跑在分布式系统上,算子并行求和顺序、硬件浮点误差这些都会引入不确定性。有点像用多台机器切面包——参数设得一模一样,机器之间的细微差别还是会让切片厚度略有出入。

同族还有两个参数:top_k 只保留概率最高的 k 个 token,再按比例缩放归一后采样,k=1 等价于贪心;seed 是在其他参数都固定时,尽量让两次调用返回相同结果。注意是"尽量"。

六、答不了公司内部问题,根子在训练数据

现在回到那个被搁置的疑点:机器人给的全是 Jira、Trello、Asana 这类通用建议,对公司现有的工具链、团队规模、预算一无所知。

这不是模型的缺陷。它就是这样的:知识全部来自训练数据,而训练数据是公开互联网语料,不包含任何一家公司的内部文档、流程和规范。

可以把它理解成一台刚出厂的服务器。CPU(推理能力)极强,硬盘(模型权重)里预装了海量通用知识,唯独"贵公司内部资料"那部分,硬盘上是空的。

那最直接的办法就是在运行时把内部知识临时告诉它——塞进系统提示词:

# ch01/Test05_knowledge.py
import sys

sys.path.insert(0, '../util')
from util import tools

client = tools.qwen_client()

if __name__ == '__main__':
    user_question = "我是软件一组的,请问项目管理应该用什么工具"
    print(f"👤 用户:{user_question}")

    knowledge = """公司项目管理工具有两种选择:

      1. Jira:对于软件开发团队来说,Jira 是一个非常强大的工具,支持敏捷开发方法,如 Scrum 和 Kanban。它提供了丰富的功能,包括问题跟踪、时间跟踪等。

      2. Microsoft Project:对于大型企业或复杂项目,Microsoft Project 提供了详细的计划制定、资源分配和成本控制等功能。它更适合那些需要严格控制项目时间和成本的场景。

      在一般情况下请使用 Microsoft Project,公司购买了完整的许可证。软件研发一组、三组和四组正在使用 Jira,计划于 2026 年之前逐步切换至 Microsoft Project。
    """

    response = tools.stream_response(
        client=client,
        user_prompt=user_question,
        system_prompt="你负责教育内容开发公司的答疑,你的名字叫公司小蜜,你要回答学员的问题。" + knowledge,
    )
    print(f"🤖 小蜜:")
    for chunk in response:
        if chunk is not None:
            print(chunk, end="")

这次它能准确回答了,甚至能区分"软件一组"和其他组的情况。把 knowledge 拼到 system prompt 后面,本质就是给模型一份开卷考试的参考资料。

问题出在量放大之后。真把员工手册、技术规范、部门职责文档全拼进去,几十页上百页,立刻会撞上第三堵墙。

七、上下文窗口,才是真正的地板

模型能接收输入的地方叫上下文窗口(Context Window)。可以把它理解成内存(RAM):容量有限,塞不下整个公司的知识库。输入一旦超过上限,直接报错。

超出长度只是最表层的麻烦。更麻烦的是这三个隐性成本:

  • :上下文越长,模型处理耗时越长,用户等待时间跟着涨。
  • :主流模型按输入和输出 token 量计费,冗余上下文都是真金白银。
  • 干扰:如果塞进去的内容大部分跟当前问题无关,等于开卷考试时给了考生一本错科目的教材。信息越多,模型判断越容易被带偏,回答质量不升反降。

所以关键不在"喂多少知识",在"喂得多准"。怎么在正确的时刻,把最相关的那几段知识精确地放进有限的窗口里——这门功夫叫上下文工程(Context Engineering)

这个视角挺值得换个位置看问题:很多大模型应用做得不好,原因不在模型智商不够,而在上下文给得不对。上下文工程大致覆盖四块:

方向解决的问题
RAG(检索增强生成)从外部知识库里检索出精准片段,作为回答依据
Prompt(提示词工程)用精确的指令引导模型的思考方式和输出格式
Tool(工具使用)让模型调用外部工具,获取实时信息或执行动作
Memory(记忆机制)为模型建立长短期记忆,支撑跨轮次、跨会话的理解

我们当前的困境正好落在 RAG 上。

RAG 的做法是:用户提问时,不把整库硬塞过去,而是先检索出与问题最相关的知识片段,再把片段和问题合并后交给模型生成答案。一共两个阶段:

建索引:把私有文档切分,用专门的 Embedding 模型把每段转成多维向量存进向量库。语义被标准化成可计算的坐标,后面才能做相似度匹配。文档量大的时候,这一步决定了检索的天花板。

检索与生成:用户提问也转成向量,去库里找最相近的几个片段,组装进提示词,然后交给模型输出答案。

到这里,第三个问题才算有了可落地的解法。它在"答不了内部问题"和"通义千问"之间加了一层检索,既不超窗口,也只喂相关内容。

回头看那三堵墙,其实是同一件事的三个切面:采样参数决定输出稳不稳,上下文喂什么决定答案对不对,而上下文窗口的大小决定你能喂多少。前两个是调参和拼字符串就能应付的,第三个没有取巧空间,只能老老实实把检索做起来。这也解释了为什么 RAG 在真实项目里几乎绕不过去——它不是为了炫技存在的,是被上下文窗口这个物理上限逼出来的工程解法。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐