如果你最近关注过大模型相关的科技新闻,可能看到过这样一条略带幽默的消息:旧金山某处广告牌上出现了 “ChatTJB” 的推广,宣传标语很有意思,大意是 “Human-powered LLM”——背后不是 GPU 集群,而是一群真人,在聊天框后面模拟大模型回复。这显然是一个带着讽刺意味的 parody:当 AI 热潮席卷一切时,到底有多少产品真的在用 LLM,又有多少只是在用人工流程“假装”智能?
抛开营销段子不谈,这块广告牌其实点出了一个非常核心的问题:我们在谈论 LLM 应用开发时,究竟在谈论什么?是 API 调用、Prompt 工程,还是 RAG、Agent、MCP、模型精度这些工程问题?这篇文章想借这个由头,从概念到代码,完整梳理一条适合后端开发者和算法工程师上手的 LLM 应用实战路径。如果你正在准备做一个 AI 客服、知识库问答系统,或者想让大模型自己去调用工具完成复杂任务,这篇文章会很有帮助。
1. 从一块广告牌说起:LLM 到底是什么
1.1 “人类供电的 LLM” 讽刺了什么
ChatTJB 的广告之所以让人会心一笑,是因为它戳破了一个行业潜台词:在很多团队里,自然语言交互界面背后的“智能”未必来自大模型,也可能来自人工客服、脚本模板、规则引擎,甚至是外包人力。这种 “Human-in-the-loop” 的做法并不丢人,很多业务流程确实需要让人工介入兜底,但它和真正意义上的 LLM 应用有两个本质区别:
- 扩展性不同:真人服务无法像模型推理一样线性扩展,遇到流量高峰时只能排队。
- 知识容量不同:模型经过海量语料预训练,具备开放领域的知识泛化能力;人工规则很难覆盖长尾问题。
广告牌用 “human-powered LLM” 这个说法,本质上是在调侃一种现象:只要聊天框做得像 LLM,用户就会默认背后有一套大语言模型。这提醒我们,在开发 LLM 应用时,不要只关心界面是否“像 AI”,更要关心模型、知识、工具和工程链路是否真的打通。
1.2 LLM 应用开发的核心问题
真正的 LLM 应用开发,通常需要回答这几个问题:
- 模型从哪里来:直接调用商业 API,还是部署开源模型?
- 知识从哪来:模型没学过的内部文档、实时数据,如何补充给它?
- 能力边界在哪:模型不会查数据库、不会操作业务系统,如何让它具备工具调用能力?
- 成本和延迟如何控制:同样一个需求,是否可以换更小的模型,或者用更少的 Token 完成?
这些问题的答案,构成了 RAG、Agent、MCP 等概念出现的背景。理解这些概念,不是让你记住名词,而是为了在做技术选型时知道什么时候该用什么。
1.3 本文的实战目标
读完这篇文章,你会完成三个层次的动手实践:
- 用 HTTP 请求调用一个 OpenAI 兼容的 LLM 接口,完成最小对话程序;
- 在不依赖重型框架的前提下,实现一个极简 RAG 知识库问答流程;
- 通过 Function Calling 让模型调用外部工具,并理解 MCP 在其中的作用。
同时,我会把高频报错、精度选择、Prompt 管理、成本控制这些工程问题整理成可以直接参考的清单。你会发现,LLM 应用开发并没有玄学,它只是把“模型”和“工程”放在一起思考。
2. 环境准备与基础概念
2.1 运行环境与依赖
本文示例以 Python 为例,建议使用 Python 3.10 或更高版本。你不需要一开始就安装一大堆框架,核心依赖只有三个:
requests:用于调用 HTTP API;numpy:用于向量计算;pandas(可选):用于演示文档数据清洗。
如果你需要本地向量检索,可以再安装chromadb或faiss-cpu;如果不想安装向量数据库,我会在示例中直接用 numpy 实现相似度检索。这种做法的优点是依赖少、逻辑清晰,适合学习原理。
创建一个项目目录:
mkdir llm-practice cd llm-practice python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install requests numpy如果你的公司内部已经部署了兼容 OpenAI 协议的模型服务,可以直接复用下面的接口地址。如果使用云端 API,就把BASE_URL和API_KEY换成自己的配置。
2.2 必备概念:Token、上下文窗口、Prompt
在写代码之前,先理解三个最基础的概念。
Token是模型处理文本的最小单位。一个 Token 不一定等于一个汉字或一个英文单词,它可能是半个词,也可能是几个字符。调用模型时,输入和输出都会消耗 Token,所以 Token 既是质量指标,也是成本指标。
上下文窗口是模型一次能看到的文本总量。比如某个模型的上下文窗口是 8K,意味着输入和输出加起来不能超过约 8000 个 Token。超过后,要么截断文本,要么对历史消息做压缩摘要,否则接口会报错。
Prompt是你发给模型的指令。它可以是简单的问题,也可以是一段包含背景信息、输出格式、例子、约束条件的系统提示词。Prompt 写得好不好,直接决定模型输出质量。
这三者的关系可以这么理解:Token 是“字数”,上下文窗口是“能看到的纸面大小”,Prompt 是“你在纸上写下的任务说明”。
2.3 模型部署方式:API 与本地推理
选择模型服务时,通常有两种方式。
第一种是使用外部 API。优点是接入快、无需 GPU、模型版本迭代由服务商维护;缺点是数据出网、单次调用有成本、可用性依赖供应商。
第二种是在内网部署开源模型,比如 llama.cpp、vLLM、Ollama 等推理引擎。优点是数据可控、边际成本低;缺点是需要 GPU 资源,还要处理模型精度、显存、并发等问题。
无论哪种方式,最好让应用层统一走 OpenAI 兼容协议。这样上层代码不用变,只需要切换BASE_URL和MODEL_NAME,就能在不同模型服务之间迁移。这也是本文代码示例采用 HTTP 接口而不是绑定某个 SDK 的原因。
3. 最小可用:写一个 LLM 对话程序
3.1 创建项目结构
在项目目录下创建配置文件config.py,把接口地址和密钥统一管理:
# 文件路径:llm-practice/config.py import os # 从环境变量读取,避免把密钥写死在代码里 BASE_URL = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") API_KEY = os.getenv("LLM_API_KEY", "your-api-key") MODEL_NAME = os.getenv("LLM_MODEL_NAME", "gpt-4o-mini")注意,这里默认使用的是 OpenAI 兼容格式。如果你用的是本地 Ollama,BASE_URL可以填http://localhost:11434/v1,模型名填如llama3.1,同样能跑通。
3.2 使用 requests 调用兼容 API
在chat.py中写一个最简单的对话函数:
# 文件路径:llm-practice/chat.py import requests import config def chat(messages, temperature=0.7): url = f"{config.BASE_URL}/chat/completions" headers = { "Authorization": f"Bearer {config.API_KEY}", "Content-Type": "application/json", } payload = { "model": config.MODEL_NAME, "messages": messages, "temperature": temperature, "stream": False, } resp = requests.post(url, headers=headers, json=payload, timeout=120) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"]这段代码做了三件事:构造请求头、发送messages列表到/chat/completions接口、解析返回的choices数组。messages是对话消息列表,通常包含system(系统指令)、user(用户输入)、assistant(模型回复),这种结构保证了多轮对话的上下文连续性。
3.3 流式输出与非流式输出
上面的例子是stream: False,模型会一次性返回完整结果。对聊天体验更友好的方式是流式输出,让文字像打字机一样逐步显示。流式接口返回的是text/event-stream格式,需要逐行解析:
# 文件路径:llm-practice/chat_stream.py import requests import json import config def chat_stream(messages): url = f"{config.BASE_URL}/chat/completions" headers = { "Authorization": f"Bearer {config.API_KEY}", "Content-Type": "application/json", } payload = { "model": config.MODEL_NAME, "messages": messages, "stream": True, } resp = requests.post(url, headers=headers, json=payload, timeout=120, stream=True) resp.raise_for_status() content = "" for line in resp.iter_lines(): if not line: continue line = line.decode("utf-8") if line.startswith("data: "): data_str = line[6:] if data_str.strip() == "[DONE]": break data = json.loads(data_str) delta = data["choices"][0]["delta"].get("content") if delta: content += delta print(delta, end="", flush=True) return content流式输出对服务端和客户端都更友好,但调试时也更容易碰到连接中断、解析失败的问题。入门阶段建议先掌握非流式调用,再升级为流式。
3.4 运行与验证
在入口文件main.py中组合消息并调用:
# 文件路径:llm-practice/main.py from chat import chat messages = [ {"role": "system", "content": "你是一个简洁、专业的技术助手。"}, {"role": "user", "content": "请用三句话解释什么是大语言模型。"}, ] result = chat(messages) print(result)运行后,你可能会看到类似下面的输出:
大语言模型是基于海量文本数据训练得到的神经网络模型,它通过预测文本序列来学习语言规律。模型能够理解自然语言指令,并根据上下文生成合理回复。它在对话、写作、代码生成等任务中表现出色。到这一步,你已经拥有一个最小可用的 LLM 对话程序。接下来要做的,是让模型具备“读你的文档”和“调用你的工具”的能力。
4. 进阶实战:RAG 知识库问答
4.1 为什么直接问模型不够
大模型的知识来自训练数据,它不知道你公司内部的最新制度、某套私有 API 的文档,也不会知道 2025 年之后发生的事件。想让模型回答这类问题,常见做法有微调(Fine-tuning)和检索增强生成(RAG)。
微调是修改模型权重,成本高、周期长;RAG 则是在不修改模型的前提下,先从外部知识库检索相关内容,再把这些内容拼进 Prompt,让模型基于材料回答。RAG 的优势在于:知识更新只需要重新索引文档,不需要重新训练模型;引用了具体来源,降低了模型“一本正经地胡说八道”的概率。
4.2 RAG 的四个步骤
一个完整的 RAG 流程可以拆成四步:
- 文档加载与切块:把 PDF、Word、Markdown 等文档读进来,按固定长度切分成多个 chunk;
- 向量化:用 Embedding 模型把每个 chunk 编码成向量;
- 检索:用户提问后,把问题也编码成向量,在向量库中计算相似度,召回 Top K 个 chunk;
- 增强生成:把召回的 chunk 和用户问题一起组装成 Prompt,交给大模型生成回答。
下面用最少的依赖实现一个极简 RAG,主要用 HTTP 请求和 numpy。
4.3 完整代码:极简 RAG
首先定义两个核心函数:一个用于调用 Embedding 接口把文本转成向量,一个用于计算余弦相似度。
# 文件路径:llm-practice/rag_utils.py import requests import numpy as np import config def get_embedding(texts): """调用 OpenAI 兼容的 embeddings 接口""" url = f"{config.BASE_URL}/embeddings" headers = { "Authorization": f"Bearer {config.API_KEY}", "Content-Type": "application/json", } payload = { "model": "text-embedding-3-small", "input": texts if isinstance(texts, list) else [texts], } resp = requests.post(url, headers=headers, json=payload, timeout=60) resp.raise_for_status() data = resp.json() return [item["embedding"] for item in data["data"]] def cosine_similarity(query_vec, doc_vec): """计算两个向量的余弦相似度""" q = np.array(query_vec) d = np.array(doc_vec) return float(np.dot(q, d) / (np.linalg.norm(q) * np.linalg.norm(d) + 1e-9))接下来,准备一批模拟文档。实际项目中,文档会从数据库或文件系统中读取,这里直接放几个片段:
# 文件路径:llm-practice/build_index.py import numpy as np from rag_utils import get_embedding documents = [ "RAG 是检索增强生成,适合处理私有知识问答场景。", "Agent 是能够自主调用工具、拆解任务的智能体。", "MCP 是模型上下文协议,用来标准化模型与外部工具之间的交互。", "FP16 可以减少显存占用,但部分场景下精度不如 FP32。", "BF16 在保持较大动态范围的同时,比 FP32 节省显存。", ] # 对文档做切块 chunks = [] for doc in documents: # 简单切块:每 20 个字符切一块,实际项目中可以按段落或句号切 for i in range(0, len(doc), 20): chunk = doc[i:i + 20] chunks.append(chunk) # 批量向量化 chunk_vectors = get_embedding(chunks) # 保存到字典 index = { "chunks": chunks, "vectors": chunk_vectors, }检索函数也比较直接,用 numpy 计算问题向量和所有 chunk 向量的相似度,取 Top K:
# 文件路径:llm-practice/retrieve.py import numpy as np from rag_utils import get_embedding, cosine_similarity def retrieve(query, index, top_k=2): query_vec = get_embedding(query)[0] scores = [] for chunk, vec in zip(index["chunks"], index["vectors"]): scores.append((cosine_similarity(query_vec, vec), chunk)) scores.sort(key=lambda x: x[0], reverse=True) return [chunk for score, chunk in scores[:top_k]]最后,把检索结果拼进系统提示词,再调用对话模型:
# 文件路径:llm-practice/rag_chat.py from chat import chat from build_index import index from retrieve import retrieve def rag_chat(question): related_chunks = retrieve(question, index, top_k=2) context = "\n".join([f"- {c}" for c in related_chunks]) prompt = f"""请基于下面的资料回答用户问题。如果资料中没有相关内容,就明确说不知道。 资料: {context} 用户问题:{question} """ messages = [ {"role": "system", "content": "你是一个知识库问答助手,只能根据资料回答。"}, {"role": "user", "content": prompt}, ] return chat(messages) if __name__ == "__main__": print(rag_chat("FP16 和 BF16 有什么区别?"))4.4 结果说明
运行rag_chat.py时,这个极简 RAG 会先把问题向量化,与文档 chunks 做相似度检索,再把最相关的两段资料拼进 Prompt。模型看到资料后会回答 “BF16 在保持较大动态范围的同时,比 FP32 节省显存” 这类信息。
这个示例很粗糙,但已经能帮助你理解 RAG 的核心思想。实际工程中,你需要考虑:
- 文档切块的粒度:太短语义不完整,太长上下文浪费;
- 向量数据库选型:Chroma、Milvus、pgvector 等;
- 检索策略:关键词检索、向量检索、混合检索;
- 重排模型:对召回结果进一步排序。
5. 进阶实战:Agent 与工具调用
5.1 让模型学会“查资料”
LLM 本身只是文本生成模型,它不具备实时查询天气、查询数据库、调用业务 API 的能力。Agent 的核心思想是:给模型提供一组“工具描述”,模型在回答过程中自主决定是否调用某个工具,并根据工具返回结果继续生成回答。
比如用户问“北京今天适合出门吗?”,模型可以根据工具描述调用get_weather,拿到天气信息后再生成回复。这个过程中,模型像是一个“调度器”,工具则是它的“手脚”。
5.2 Function Calling 示例
OpenAI 兼容协议中,工具调用通常通过tools参数声明。下面用 requests 实现一个简化版:请求模型判断是否调用get_weather工具,然后解析返回结果。
# 文件路径:llm-practice/tool_call.py import requests import json import config tools = [ { "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的天气情况", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名"} }, "required": ["city"] } } } ] def get_weather(city): # 示例函数,实际项目中应调用真实天气服务 return f"{city} 今天晴,气温 24 摄氏度,适合出门。" def call_llm_with_tools(messages): url = f"{config.BASE_URL}/chat/completions" headers = { "Authorization": f"Bearer {config.API_KEY}", "Content-Type": "application/json", } payload = { "model": config.MODEL_NAME, "messages": messages, "tools": tools, "tool_choice": "auto", } resp = requests.post(url, headers=headers, json=payload, timeout=120) resp.raise_for_status() return resp.json() def run_agent(user_query): messages = [{"role": "user", "content": user_query}] response = call_llm_with_tools(messages) message = response["choices"][0]["message"] # 判断模型是否请求调用工具 if message.get("tool_calls"): for tool_call in message["tool_calls"]: func_name = tool_call["function"]["name"] args = json.loads(tool_call["function"]["arguments"]) if func_name == "get_weather": observation = get_weather(args["city"]) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": observation, }) # 将模型第一次回复的消息追加到上下文,再让模型基于工具结果生成最终答案 messages.append(message) final_response = call_llm_with_tools(messages) return final_response["choices"][0]["message"]["content"] else: return message["content"] if __name__ == "__main__": print(run_agent("北京今天适合出门吗?"))这个流程最关键的地方在于消息序列:模型先返回一个tool_calls,应用层执行对应工具后,把工具结果以role: "tool"的消息追加到对话中,然后再次调用模型。模型看到工具返回结果后,才会生成面向用户的最终回答。
5.3 MCP:标准化工具协议
上面这个 Function Calling 示例还有一个小问题:如果模型要调用的是 10 个不同团队提供的服务,每个服务的协议都不一样,Agent 的代码会变得非常臃肿。MCP(Model Context Protocol,模型上下文协议)就是为了解决这个问题而出现的。
MCP 可以理解为模型与外部工具之间的“USB 接口”。它定义了客户端、服务器和工具之间的标准通信方式。一个 MCP Server 可以把数据库、文件系统、Web 服务统一暴露给 MCP Client,LLM 应用只需要连接 Client,就能按标准格式发现并调用这些工具。MCP 目前还在快速演进中,不同语言生态的 SDK 也不断变化,所以本文不贴具体代码。你只需要先理解:Agent是一种应用架构思想,而MCP是这种架构中的标准化通信协议。
5.4 编排框架的选择
有人会问:既然我可以用 requests 直接调用接口,为什么还需要 LangChain、LlamaIndex 这类编排框架?
答案可以从两个角度看。如果你的业务逻辑非常简单,比如只做一次 RAG,直接用 HTTP 请求反而更容易控制和排错。但如果你的业务涉及多步推理、多工具编排、长期记忆、状态管理,框架能帮你省去很多重复代码。框架提供的抽象能力包括:Chain 流程编排、Memory 记忆管理、Retriever 统一接口、Callback 回调机制等。
不过框架的缺点也很明显:抽象层级越多,越不容易理解底层发生了什么。建议初学者先用原生代码实现最小案例,再用框架重写一遍,这样你会知道框架帮你做了什么,也更容易排查问题。
6. 常见问题与排查思路
LLM 应用开发过程中,很多报错都是相似的。下面整理一份高频问题排查表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 401 Unauthorized | API Key 无效或未配置 | 检查环境变量LLM_API_KEY,确认密钥没有过期 |
| 404 Not Found | 接口路径或模型名不存在 | 确认BASE_URL是否包含/v1,确认模型名与部署一致 |
| embedding 维度不一致 | 文档向量和查询向量使用了不同模型 | 统一使用同一个 Embedding 模型,重新生成向量索引 |
| 请求超时 | 模型推理时间过长或网络原因 | 调大timeout;改用流式接口;检查网络 |
| 上下文长度超限 | 输入文本超过模型上下文窗口 | 压缩历史消息,或使用摘要记忆;减小文档 chunk 长度 |
| 输出内容不稳定 | Prompt 指令不够具体 | 增加格式要求、few-shot 示例,降低 temperature |
| 中文回答质量差 | 模型对中文指令理解不足 | 尝试换中文能力更强的模型,并用更明确的中文示例 |
| 工具没有被调用 | 模型认为不需要工具,或工具描述不清晰 | 检查工具描述是否准确,适当使用强制调用参数tool_choice |
| 文本向量 API 未配置 | 使用了 RAG 功能但没有配置LLM_EMBEDDING_API_KEY | 在配置文件中增加 Embedding 单独配置项,并检查模型名 |
排查问题时,建议遵循“先看请求,再看响应,最后看日志”的顺序。第一次运行时可以打印请求 payload 和响应 status code,确认接口地址、模型名、参数格式都没问题。多轮对话和工具调用中的问题,还要额外检查 messages 列表是否合法,比如tool_call_id是否对应正确。
7. 最佳实践与工程建议
7.1 精度选择:FP16、FP32、BF16
如果你要在本地部署模型,精度选择直接影响显存占用和推理效果。
- FP32(32 位浮点数)精度最高,但显存占用大,适合小模型或调试场景。
- FP16(16 位浮点数)显存占用减半,速度更快,但数值范围较小,训练或推理时可能出现精度溢出。
- BF16(BFloat16)用更多的指数位保留动态范围,更适合大模型训练和推理,在实践中比 FP16 更稳定。
选择精度时,可以先在 FP16/BF16 下跑一批测试用例,和 FP32 结果对比。如果业务对输出质量要求很高,不要为了省显存盲目降低精度。还可以利用量化方法,比如 8-bit 和 4-bit,但也要评估对效果的损失。
7.2 Prompt 管理与版本化
Prompt 不是随便写的自然语言,它是应用的一部分。比较好的做法是:
- 统一模板管理:Prompt 模板放在配置中心或版本库中,不要散落在业务代码里;
- 版本化:每次改动记录版本,方便回滚和 A/B 测试;
- 包含输出格式:明确要求模型输出 JSON、Markdown 或纯文本,便于后续程序解析;
- 设置兜底:在 Prompt 中要求模型“如果不知道就回答不知道”,降低幻觉风险。
7.3 成本与延迟控制
LLM 应用上生产环境,最大的两个隐患往往是成本和延迟。
成本方面,可以加入缓存层,把相同问题的高频回复缓存起来;也可以根据问题复杂度选择不同规格的模型,简单问题用便宜模型,复杂问题用强模型。合理设置max_tokens,避免输出过长浪费 Token。
延迟方面,流式输出能显著改善用户体验;把文档切块后的向量索引提前构建,避免在请求链路上做重计算;对 RAG 场景,可以考虑使用更小的重排模型来提升响应速度。
7.4 安全与合规
涉及 LLM 应用时,安全和合规问题不能忽略。
- 权限最小化:给服务配置的 API Key 应只具备所需权限,不要使用管理员级密钥;
- 数据出网评估:敏感业务数据如果要调用外部模型接口,必须确认是否允许;
- 输入过滤:对用户输入做长度限制和内容安全过滤,避免恶意 Prompt 注入;
- 输出校验:模型返回内容如果是结构化数据,要做字段校验和异常兜底;
- 操作类工具必须授权:比如数据库写入、系统操作,必须在工具层做权限校验,不能只看模型是否决定调用。
7.5 测试与可观测性
LLM 应用测试和传统应用测试不太一样。传统测试关注“对错”,LLM 测试需要关注“质量和稳定性”。可以建立一套评测集,把常见问题、边界问题和敏感问题都固定下来,每次模型或 Prompt 变更后回归测试。同时要记录关键链路日志,包括请求消息、工具调用、返回结果、Token 消耗、耗时等。没有日志,你很难定位是 Prompt 问题、模型问题还是业务代码问题。
8. 回到广告牌:工程思维比热闹更重要
旧金山那块 “human-powered LLM” 广告牌,放在今天的语境下,既是一种幽默,也是一种提醒。它提醒我们,用户看到的自然语言界面只是一个外壳,真正决定产品价值的,是外壳背后的工程链路是否可靠。
你可能不会真的用一群人去模拟 LLM,但如果不理解 RAG 的检索质量,不关注模型精度和成本,不建立 Prompt 管理和可观测机制,你的程序也可能在用户面前 “假装智能”,因为它的每一次回复都过于依赖运气。
这篇文章从最小对话程序开始,逐步实现了一个极简 RAG,又通过 Function Calling 介绍了 Agent 的玩法。你可以把这些代码运行起来,替换成自己的文档和工具,然后尝试用 LangChain 或 LlamaIndex 重新实现一遍。你会发现,当底层原理已经清楚时,框架只是你工具箱里的螺丝刀而已。
如果这篇文章对你有帮助,记得收藏备用。接下来,可以继续关注 MCP 的演进,也可以深入了解本地推理引擎的优化方法。实践出真知,动手跑一跑,你会比只看广告牌的人懂得更多。