资讯动态

LLM应用开发实战:从API调用到上下文管理与流式输出

发布时间:2026/10/17 4:12:25 来源:尧图企业网站定制
过去两个月我几乎把收藏夹里所有关于大模型的分析文章翻出来重看了一遍最后发现一个扎心的事实看过一百篇“一文读懂大模型”不如亲手调通一次 LLM API。这篇是我第一阶段 LLM 应用开发学习的总结也是系列的第一篇。如果你已经会写 Python但还没真正把一个对话模型接进自己的代码里这篇文章应该能帮你少走不少弯路。聊点实在的LLM 应用开发不是科研也不是算法岗那种“从零训练模型”的活它的核心是“把现成的模型能力变成产品功能”。你会接触到的无非是接口调用、Prompt 设计、上下文管理、流式输出、结构化返回这些事。听起来不复杂但每个环节里都埋着不少坑。接下来我会按我自己的实践路径从环境准备、第一个可运行的程序到记忆管理、流式输出和排错习惯一条线讲完。1. 为什么是现在LLM应用开发的入场姿势1.1 从“训练模型”到“编排模型”的思路切换我见过不少人一听说“大模型应用开发”第一反应是“那我得先学 PyTorch 训练一个模型”。这个想法会直接把人劝退因为方向错了。今天我们能做的应用开发更像是“把模型当水管接进业务里”你要设计的是水管怎么走、水压怎么控、阀门什么时候开而不是去研究水厂怎么净水。这个认知转变非常重要。我第一阶段实践里最大的收获就是接受了“模型是别人训练的我只负责编排”这个设定。你要做的是决定什么时候调用模型什么时候别调用设计输入给模型的指令和上下文处理模型返回结果并做好异常兜底把模型能力封装成别人能用的功能说白了你不需要懂 Transformer 怎么反向传播但你需要非常清楚 API 的输入输出长什么样以及模型在不同参数下的行为差异。这两者之间隔着的东西就是“应用开发”四个字的分量。1.2 第一步应该划定哪些边界我给自己定了一條规矩第一阶段只围绕“对话补全”这条主线展开。也就是输入一段文本模型返回一段文本不碰微调、不碰 RAG也不碰 Function Calling。不是这些不重要而是先要把最基础的一条链路彻底跑通后面加任何东西心里才有底。实际从整个应用生态看目前大量场景都能归到几个常见类型上问答客服、内容生成、数据提炼、流程自动化里的文本处理。这些场景的底层能力模型已经给了我们要做的只是组合和编排。所以第一阶段的实践目标很清晰把“文本进、文本出”这条主干做到熟练包括参数调整、上下文裁剪、流式读取、结构化输出。明确了边界之后后面的每一步都像是在搭积木每次只加一块出问题的时候就能立刻判断是哪块积木的问题。2. 动手前的准备API选型与项目骨架2.1 选接口的注意力放在哪里我选模型服务商的时候没有太纠结模型本身有多强因为各家的模型能力都在快速迭代今天最强的可能下个月就被追平了。我更看重四个维度这也是我建议你先想清楚的事维度我会问自己什么为什么关键接口格式标准化程度换一个服务商我的代码改动量大不大避免被一家绑定迁移成本低计费结构输入和输出是否分开计价输出比输入贵直接影响成本预算上下文窗口规格是 8K、32K 还是更大决定能塞多少历史对话和文档限流与并发策略单请求最大并发多少超了怎么办影响生产环境稳定性现在多数主流服务商都提供标准化的 chat completions 接口格式基本都能复用同一套请求结构只是地址、密钥和模型标识不一样。这一点建议优先确认因为接口标准化程度越高以后切换服务商越轻松踩坑后跑路的成本也越低。2.2 最小化项目骨架与密钥管理环境这块没什么玄学就是 Python 3.10 以上建一个干净虚拟环境装两个库就够起步python -m venv .venv source .venv/bin/activate pip install requests python-dotenv我没用各家封装好的官方 SDK而是直接用requests调 HTTP 接口。原因是第一阶段我希望看到完整的请求和响应结构用 SDK 虽然省事但很多时候它会帮你把细节藏起来出了问题反而不好定位。先把裸接口吃透后面想用 SDK 随时能换。密钥管理是很多人一开始容易忽略但迟早会吃亏的点。密钥绝对不能硬编码在代码里也不要写进会提交到仓库的文件。我习惯用.env文件存配置# .env API_KEYsk-your-key-here BASE_URLhttps://your-provider.example.com/v1 MODEL_NAMEyour-chat-model然后代码里通过python-dotenv加载import os from dotenv import load_dotenv load_dotenv() API_KEY os.getenv(API_KEY) BASE_URL os.getenv(BASE_URL) MODEL_NAME os.getenv(MODEL_NAME)同时建一个.gitignore把.env排除在外。有些朋友喜欢把配置写到代码里临时测试一旦提交到公开仓库密钥基本就等于裸奔了。我会说密钥泄露这件事一次就够你长记性不如从一开始就养成“代码里无密钥”的习惯。3. 第一个实战搭一个能对话的命令行机器人3.1 看懂聊天接口的请求与响应格式几乎所有现代对话模型接口都遵循同一个设计思路你发送一个messages数组数组里是若干条消息每条消息有role和content。role通常有三种system给模型设定角色和行为边界user用户输入assistant模型的回复多轮对话时会用到请求里最常用的几个参数也值得先搞清楚因为后面所有调试都绕不开它们。temperature控制随机性0 到 2 之间越低越稳定、越高越发散max_tokens限制本次回复的最大长度消息数组则描述“模型已经看到了什么”。响应里choices[0].message.content就是模型生成的正文usage字段记录这次请求消耗的 token 数量。我第一次看到这个结构的时候脑子里冒出来的类比是你其实不是在与一个“脑子”聊天而是在给一个“图书馆管理员”下指令。他每次被叫到名字才开始工作工作完就把刚才的事忘干净除非你把之前的对话记录一张张递到他面前。3.2 代码落地与运行效果我把整个流程封装成一个函数输入用户文本返回模型回复import os import requests from dotenv import load_dotenv load_dotenv() API_KEY os.getenv(API_KEY) BASE_URL os.getenv(BASE_URL) MODEL_NAME os.getenv(MODEL_NAME) def chat_once(user_input: str) - str: url f{BASE_URL}/chat/completions headers { Authorization: fBearer {API_KEY}, Content-Type: application/json, } payload { model: MODEL_NAME, messages: [ {role: user, content: user_input}, ], temperature: 0.7, max_tokens: 512, } try: resp requests.post(url, headersheaders, jsonpayload, timeout60) resp.raise_for_status() data resp.json() return data[choices][0][message][content] except requests.exceptions.Timeout: return 请求超时了稍后再试 except requests.exceptions.HTTPError as e: return f接口返回错误{e}主循环写清楚交互逻辑def main(): print(简单对话机器人输入 exit 退出) while True: user_input input(你) if user_input.strip().lower() in (exit, quit): break reply chat_once(user_input) print(fAI{reply}) if __name__ __main__: main()运行之后的效果大概就是一段你来我往的对话。我这个最低限度版本跑通之后做的第一件事不是加功能而是故意把请求参数改错几次看看报错信息长什么样。这一步很值得做因为你对错误的熟悉程度决定了以后排错的速度。3.3 温度、长度与返回结构的调参直觉temperature这个参数第一眼看很简单但实际调起来挺微妙。一次我在处理一份产品文案时把温度设成 1.6结果同一段要求模型给五种不同的表达方式前两条还行后面直接开始“放飞自我”连格式都变了。后来我做固定格式提取时把温度压到 0结果稳定得像换了个模型。我的习惯是这样的需要稳定事实或格式化输出的场景temperature设在 0 到 0.3头脑风暴或创意文案场景temperature设在 0.7 到 1.2更高就很少用了除非刻意追求发散还有一个容易被忽视的点是max_tokens。它不仅仅是“回复长度上限”同时也是控制成本的工具。模型会先“想”再“写”输出的每个 token 都比输入贵所以不给输出设上限等于把账单的空白金额交给模型随便填。我每次调参都会顺手看一眼usage字段确认输入输出 token 比例这个习惯对预算控制帮助很大。4. Prompt工程的第一课上下文窗口与对话记忆4.1 模型为什么“记不住”上一句话单轮对话跑通之后我很快发现一个让人抓狂的问题模型不记得自己刚才说过什么。你在第一轮告诉它“我叫小明”第二轮问它“我叫什么”它会一本正经地说“我不知道”。这个问题的根因在于接口本身是无状态的。模型每一次调用都是独立完成的上一次调用产生的上下文存在于那一次调用的内存里调用结束就释放了。所以如果你不主动把历史对话随着请求再发一遍模型就是一个失忆症患者。搞清楚这一点之后很多关于“模型记性差”的抱怨就没必要了。你需要的不是期待模型有记忆而是自己管理记忆然后把记忆放进messages数组里传给模型。4.2 用Messages数组做一个简单记忆层改造思路很简单维护一个消息列表每次请求前追加用户输入拿到回复后追加模型输出再整体发出去。messages [ {role: system, content: 你是我的编程助手回答尽量简洁需要时给出代码示例。}, ] def chat_with_history(user_input: str) - str: messages.append({role: user, content: user_input}) payload { model: MODEL_NAME, messages: messages, temperature: 0.4, max_tokens: 512, } resp requests.post( f{BASE_URL}/chat/completions, headers{ Authorization: fBearer {API_KEY}, Content-Type: application/json, }, jsonpayload, timeout60, ) resp.raise_for_status() data resp.json() reply data[choices][0][message][content] messages.append({role: assistant, content: reply}) return reply这里有个细节值得注意我在messages的第一条放了system消息。它的作用不是跟用户说话而是给模型一个“内在人设”。比如“你是一个编程助手”“你是客服态度温和不超过三句话”这些约束放在system里比塞在用户输入里稳定得多。我在一开始没用好它结果每条用户消息都要重复一遍角色设定不但费 token而且容易把模型绕晕。4.3 上下文窗口的预算与裁剪策略加了记忆之后新问题接踵而至历史消息会无限膨胀。对话越聊越长每次请求携带的上下文越来越大最后不是报错就是账单飙升。这就涉及上下文窗口的预算管理。所谓上下文窗口是模型一次能处理的最大 token 总量常见规格有 8K、32K、128K 等窗口是输入和输出共享的预算。我做过一个粗略计算假设窗口是 8K约 8000 token系统提示占 300 token用户本轮输入 1000 token累积对话历史 3500 token那么留给本次回复的最大空间就是 8000 减 300 减 1000 减 3500约 3200 token。一旦超出预算接口就会报上下文超限错误或者直接截断信息。我目前用的是一个很朴素的滑动窗口策略只保留系统提示和最近若干轮对话更早的内容直接丢掉。def trim_history(messages, keep_rounds10): system_msg messages[0] rest messages[1:] if len(rest) keep_rounds * 2: rest rest[-(keep_rounds * 2):] return [system_msg] rest注意每一轮对话会产生两条消息用户助手所以keep_rounds * 2才是消息条数。裁剪虽然粗暴但对第一阶段的多数场景够用了。更复杂的做法是用模型对旧对话做摘要将摘要作为系统提示的一部分但那是后面要研究的事。我在实践中还发现一个技巧如果能用数据库单独保存用户关键信息比如用户偏好、项目名那就不必把所有历史都塞给模型只需把关键信息拼进系统提示能省下大量 token。5. 进阶一点流式输出与结构化返回5.1 流式输出把等待变成打字机效果单轮响应通常需要两三秒甚至十几秒如果让用户盯着一片空白界面等待体验非常糟糕。我第一个带界面的小项目就是在这里吃了亏请求发出去之后用户以为程序卡死了。解决方案是流式输出。核心思路是请求里加一个stream: true接口不再返回一次性 JSON而是以数据流的方式逐块返回生成内容我们客户端逐行读取并实时打印。payload { model: MODEL_NAME, messages: messages, stream: True, } with requests.post( f{BASE_URL}/chat/completions, headersheaders, jsonpayload, streamTrue, timeout120, ) as resp: for line in resp.iter_lines(): if not line: continue line line.decode(utf-8) if not line.startswith(data:): continue data_str line[len(data:):].strip() if data_str [DONE]: break chunk json.loads(data_str) delta chunk.get(choices, [{}])[0].get(delta, {}) piece delta.get(content, ) if piece: print(piece, end, flushTrue)流式响应的消息格式是 SSE服务端事件流每一段以data:开头最后以[DONE]收尾。评论区有人问过为什么流式和普通响应的返回结构不一样因为流式模式下模型每生成一小段就把结果推给你你看到的是长回答的切片而不是整块结果。这种“打字机效果”不仅在观感上大幅提升还有一个实际好处用户可以在生成过程中看到部分内容从而判断方向是否跑偏不用等全部生成完才发现答非所问。5.2 JSON模式与半结构化提取另一件让我感觉“这玩意能干活”的事是让模型输出固定结构的 JSON。对话能力的上限只是自然语言但业务流程里很多时候需要程序能直接消费的数据比如从一段客户反馈里提取意向等级、从一篇文章里抽取标题和摘要。如果直接让模型“用 JSON 格式输出”偶尔会混入解释性的文字下游json.loads直接报错。更稳妥的做法是在请求里明确声明response_format再在提示词里给出字段定义和示例。payload { model: MODEL_NAME, messages: [ {role: system, content: 你是信息提取助手只输出 JSON。}, {role: user, content: 请从下面内容中提取意向等级和原因\n text}, ], response_format: {type: json_object}, temperature: 0, }下面是我踩过的一个具体坑response_format声明了 JSON 输出不代表模型输出的字段一定是你想要的。比如你要求{level: 高 | 中 | 低}模型可能给你{level: 较高}。所以解析时依旧要做一层字段校验不能盲目信任。import json raw data[choices][0][message][content] try: result json.loads(raw) except json.JSONDecodeError: result {level: unknown, reason: 解析失败}这套组合拳用熟了之后我已经可以把模型当成一个“智能表单填写助手”让它从非结构化文本中填出结构化字段这算是第一阶段最让我有成就感的功能。6. 基础阶段最常踩的坑与我的调试习惯6.1 高频报错与排查对照我把第一阶段遇到的报错整理成了一张表遇到类似问题可以直接对照报错表现常见原因处理方式401 authentication_errorAPI Key 错误、格式不对或密钥已失效检查.env加载确认密钥没带多余空格429 rate_limit_exceeded请求频率超限或者账户额度不足降低请求频率加退避重试逻辑400 context_length_exceeded输入加上输出超过上下文窗口上限裁剪历史消息缩短输入提高摘要使用频率400 invalid_request_errormessages格式不对比如role用了非法值核查每条消息是否只有role和content两个必需字段timeout / connection error网络波动或服务端响应过慢设置合理超时对长任务使用流式 更长超时时间其中最容易误导人的是context_length_exceeded。我第一次遇到时以为只是输入太长后来打印了usage才反应过来输出也是占用窗口配额的。模型生成的回复越长对窗口的占用越大所以排查这类超限问题时不仅看输入还要看max_tokens和实际输出长度。还有一个很低级但很容易犯的错误把请求头写错了。有几次我漏传了Content-Type: application/json服务端返回的报错信息非常难懂排查半天才发现是请求头的问题。建议任何报错都先确认三件事请求地址是否正确、请求头是否完整、payload 字段名是否拼写正确。6.2 让我少踩坑的四个调试习惯进入跑通阶段之后我逐步养成了几个调试习惯对提升效率帮助很大第一最小化复现。遇到问题第一反应不是去翻日志而是把请求压缩到最短一条消息、一个参数、一种场景确认问题还能不能复现。我曾经在一个庞杂的多轮上下文里排查输出乱码问题折腾了半天后来抽离成单条输入才发现问题是系统提示里有一个错别字导致模型误解了任务。第二固定随机性。排查问题时把temperature设成 0。同一个请求跑十次结果都一样时对比起来才方便。如果设成 0 之后问题消失那大概率是采样随机性导致的而不是代码或配置问题。第三打印请求摘要。每次请求结束后记录状态码、耗时、输入 token 数、输出 token 数。这些数据积累起来能帮你判断不同写法对成本和延迟的影响。我现在很多关于“上下文到底该留多少轮”的判断都来自这些日志而不是猜。第四先用服务商自带的调试台验证意图再查代码。同样的功能在网页调试台上跑一下如果结果是正常的那就说明问题出在我的代码封装上如果在调试台上也有问题那就是请求格式或模型能力边界问题不需要在自己的代码里反复试。这一步能省下大量时间。这几个习惯单独拎出来都不稀奇但真正每次都做到的人不多。尤其是“最小化复现”几乎是所有排错工作的起点越早养成就越少吃亏。从第一次看清接口返回的 JSON到流式输出跑出打字机效果再到 JSON 模式成功从文本里提出结构化数据这一阶段的实打实收获比我想象中多。最后分享一个小经验不要在一个晚上试图跑通所有功能把每个小功能拆成一个独立的 Python 文件比如chat_flow.py、chat_stream.py、chat_json.py每个文件只做一件事后面排查问题会轻松很多。我自己就靠这个办法在遇到异常时能快速定位到具体模块而不是在一坨代码里大海捞针。下一篇我打算聊聊检索增强生成RAG把那块骨头啃完再回来分享。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑