先说明一下:这个系列我在做的时候,给自己立了一条规矩——不动用任何现成的智能体框架,不引入任何封装好的SDK,完全从最底层、最原始的大模型调用开始写。很多人做智能体,第一反应就是装框架、拖组件、接插件,结果做了半天,连"模型到底是怎么被调起来的"都说不清楚。这不是坏事,但出了问题也很难排查。所以我决定把这个项目直接摊开来写,第一天就老实交代:JchatMind智能体最初的样子,其实就是一段发往大模型API的HTTP请求,外加一个循环读取返回内容的终端脚本。这篇就是给你完整还原这个过程,包括我从零开始写的每一步代码、踩过的每个坑,以及为什么这么设计。这个系列适合两类人看:一类是刚接触大模型应用开发、想弄清楚"智能体到底由什么组成"的初学者;另一类是已经用过各种框架、但想回头把地基夯实,遇到问题不至于抓瞎的开发者。1. 先讲清楚:JchatMind的第一天,为什么非要"退回到原始时代"很多教程会教你直接上手某个框架,把大模型的调用封装成一行函数。但我的观点很朴素:你要做一个智能体,首先得知道一次"大模型调用"在机器层面上究竟发生了什么,否则后面所有的记忆管理、工具调用、流程编排,都是踩在沙子上盖楼。1.1 智能体的地基,其实只有三件事我理解的智能体,剥掉所有花哨概念后只剩下三块:模型调用:让大模型根据输入文本生成输出文本。上下文管理:把"之前的聊天内容"作为输入的一部分,让模型看起来"记住了"对话。工具调用:让模型在需要的时候,输出一个"我要执行某个函数"的指令,程序去执行,再把结果喂回给模型。这三件事,没有任何一件是非要用框架才能完成的。第一天的JchatMind,我要求自己连第一步"模型调用"都必须能徒手写出来——精确到HTTP请求长什么样、响应怎么解析。1.2 框架隐藏了太多"本该知道"的细节举个例子,很多人第一次用封装好的大模型库,只是传了一个prompt就拿到了结果。但模型到底是怎么"看懂"system message的?temperature调到0.9和0.2有什么差别?为什么返回内容有时候是断裂的?流式输出时data: [DONE]到底代表什么?这些细节,框架不会替你想,但它们恰恰决定了智能体的稳定性。从原始调用开始,并不是为了炫技,而是为了给后续排查问题留一条清晰的逻辑链。我第一天踩的几个坑,后面会挨个展开,全部都是底层细节没吃透导致的。1.3 什么时候才轮到框架出场我也不是在否定框架。当项目跑到一定规模——要对接多个模型、要编排复杂工作流、要接入外部知识库——这时候引入框架节约成本是合理的。但前提是你已经对"一次裸调用"了如指掌。这样框架出问题的时候,你能判断出是框架的问题,还是模型本身的问题。第一天,我先把这层底子打牢。2. "最原始调用"到底长什么样:一次HTTP请求的内在结构先建立一个认知:目前主流的大模型API,本质上就是一个HTTP POST接口。你把一段文本通过JSON格式发过去,服务端把模型生成的结果通过JSON返回来。看起来高深莫测,拆开其实非常直白。2.1 请求体里的每个字段,都是什么意思大模型API的请求地址,通常形如:POST /v1/chat/completions请求头需要两个东西:Content-Type: application/json,以及Authorization: Bearer 你的API密钥。请求体核心字段大概就这几个:字段作用我的建议model指定用哪个模型根据任务选,对话类选通用旗舰,结构化任务选支持严格JSON输出的messages对话消息列表,包含角色和内容核心字段,智能体的所有记忆都体现在这里temperature控制随机性,0到1或更高写代码用0.2以下,创意写作可以0.7以上max_tokens限制本次最大生成长度不设可能超出预期,设了能控制成本stream是否流式返回做对话产品建议true,做批量处理建议false2.2 messages数组:智能体"记忆"的真相这个设计很多人第一次接触会懵。它不是只把"用户最新一句话"发给模型,而是要发给模型一个数组:[ {"role": "system", "content": "你是一个智能助手,名字叫JchatMind。"}, {"role": "user", "content": "你好,请介绍一下自己"}, {"role": "assistant", "content": "你好,我是JchatMind,一个智能体助手。"}, {"role": "user", "content": "你刚才说你叫什么?"} ]用大白话讲,你把对话历史整个摊开在模型面前,模型"读"一遍,然后接着往下写。它没有真正记住任何东西,它只是每次都在"看完聊天记录后接着回应"。这带来两个很重要的推论:上下文越长,花的token越多,速度越慢。程序如果把无关紧要的历史全塞进去,模型的表现会被稀释。2.3 角色分工:system、user、assistant分别管什么这三个角色,相当于一个团队的三种分工:system:给模型定基调、立规矩。它是"幕后的导演",用户看不到但对行为影响最大。user:用户的发言。assistant:模型自己的历史回复。在连续对话中必须把它带上,模型才知道"自己之前说过什么"。我在第一天犯过一个典型错误:连续对话没带assistant的历史回复,导致模型每次回答都像失忆一样。后面排查到原因,才真正想通这个数组的设计逻辑。3. 不借助任何SDK,用curl把第一句话送给模型理论说完了,直接动手。第一步我不写代码,先用最原始的工具——curl——来验证模型接口通不通。这个过程不需要任何依赖,是排查连通性最快的方法。3.1 准备环境变量,别把密钥写死在代码里我在项目根目录建了一个.env文件,把API密钥和模型名放进去:export JCHAT_API_KEY="你的密钥" export JCHAT_MODEL="your-model-name" export JCHAT_API_BASE="https://your-api-endpoint.example.com/v1/chat/completions"把密钥放到环境变量里是个好习惯。不然代码写错提交到仓库,密钥就泄露了。后面写Python时也需要读取这两个变量。3.2 真正发出去的第一条请求在终端执行:curl "$JCHAT_API_BASE" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $JCHAT_API_KEY" \ -d '{ "model": "'"$JCHAT_MODEL"'", "messages": [ {"role": "system", "content": "你是JchatMind,一个简洁、准确的智能助手。"}, {"role": "user", "content": "请用一句话说明你的身份。"} ], "temperature": 0.3, "max_tokens": 200 }'等几秒,会看到返回的JSON长这样:{ "id": "chatcmpl-xxx", "object": "chat.completion", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "我是JchatMind,一个由简化流程驱动的智能助手。" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 28, "completion_tokens": 15, "total_tokens": 43 } }看到这个返回,说明最底层的通信已经打通了。我把这个时刻视为JchatMind的"开机自检"。3.3 响应字段逐个拆解choices[0].message.content:模型生成的正文,这是主角。choices[0].finish_reason:为什么结束。stop表示自然结束,length表示触达max_tokens上限,tool_calls表示模型想调用工具。usage.total_tokens:本次调用消耗的token总量。做成本优化的时候盯这一个字段就够了。有个坑值得提前说:如果你收到的content是null,但finish_reason是tool_calls,说明模型没有直接回答,而是想调用工具。这种返回值我第一天没处理,直接解析content就报了空指针,后面专门写了工具调用模块才解决。4. 用代码完成"对话闭环":从一次请求到连续对话curl能通,说明地基可以开工了。但JchatMind不能靠手敲curl活着,我需要把它变成可复用的Python代码。这个阶段,我不装任何第三方大模型SDK,只依赖requests库。4.1 最小的调用函数:入参是消息数组,出参是模型回复import os import json import requests API_BASE = os.getenv("JCHAT_API_BASE") API_KEY = os.getenv("JCHAT_API_KEY") MODEL_NAME = os.getenv("JCHAT_MODEL") def chat_once(messages, temperature=0.3, max_tokens=1024): headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", } payload = { "model": MODEL_NAME, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } resp = requests.post(API_BASE, headers=headers, json=payload, timeout=60) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"]这个函数非常简单,但它划出了整条链路的边界:入参是message数组,出参是模型文本。后续所有智能体能力——记忆、工具调用、流程控制——都在这个函数外面加逻辑,不会污染它的本质。4.2 连续对话的关键:把每轮内容都追加到messages里我一开始天真地以为,连续对话就是把用户的新问题追加进数组。结果模型不但忘记了自己说过什么,连用户第一句话都记不住。原因是:我只追加了user消息,没把assistant的回复也追加进去。正确做法是这样:messages = [ {"role": "system", "content": "你是JchatMind,回复简洁准确,不编造信息。"}, ] while True: user_input = input("你: ") if user_input.lower() in ("exit", "quit"): break messages.append({"role": "user", "content": user_input}) reply = chat_once(messages) print(f"JchatMind: {reply}") messages.append({"role": "assistant", "content": reply})这段代码就是JchatMind第一天的"对话闭环":输入、调用、输出、回填历史。4.3 上下文无限膨胀的隐患跑着跑着我发现一个新问题:对话越长,每次请求越慢,费用越高。原因很简单——messages被撑得越来越大。模型每次都要把全部历史重新读一遍,token成本线性上涨。第一天的简陋策略是截断:def trim_messages(messages, max_messages=20): if len(messages) <= max_messages: return messages return [messages[0]] + messages[-(max_messages - 1):]默认保留system消息,然后只保留最近若干轮对话。这个策略很粗糙,但它让我理解了:智能体的记忆管理,本质上是一个"在信息和成本之间做取舍"的动态过程。后续版本会换成token数裁剪,但第一天的思路是正确的。4.4 流式输出:让模型"边说边吐"而不是"憋完才说"对话产品不做流式输出,体验会很差。用户发一句话,页面要转圈好几秒才一次性出结果。流式输出是让模型生成一段就传一段,终端或前端就能边收边显示。启用流式很简单,把stream设为true:def chat_stream(messages, temperature=0.3): headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", } payload = { "model": MODEL_NAME, "messages": messages, "temperature": temperature, "stream": True, } with requests.post(API_BASE, headers=headers, json=payload, stream=True, timeout=120) as resp: resp.raise_for_status() for line in resp.iter_lines(): if line: line = line.decode("utf-8") if line.startswith("data:"): data_str = line[5:].strip() if data_str == "[DONE]": break chunk = json.loads(data_str) delta = chunk["choices"][0]["delta"].get("content", "") if delta: print(delta, end="", flush=True)这段代码的核心是处理SSE(Server-Sent Events)流。每一行以data:开头,后面跟着一段JSON。[DONE]是结束标记。我第一天在这里栽了个跟头,后面会详细说。5. 给JchatMind装上"手和眼睛":最原始版本的工具调用设计对话闭环打通之后,JchatMind已经能"聊"了。但智能体和聊天机器人的最大区别在于:智能体能调用外部工具,改变真实世界的状态。比如查天气、算数学、读写文件。第一天我决定做一个极其粗糙但逻辑完整的工具调用原型。5.1 工具调用的本质:模型只出"指令",程序来执行很多人误以为大模型调用工具是"模型直接联网执行操作"。实际上模型根本不会执行任何东西,它只是输出一段结构化文本,描述"我建议调用哪个函数、传什么参数"。真正动手的是你的程序。打个比方:模型是个"只会发号施令的顾问",它写一张便签说"请给我拿一杯水,温度70度",你作为执行者读便签、去倒水、把水端回来,再告诉顾问"水来了"。整个过程顾问并没有真实接触到水。5.2 用一个字典管理可用的"工具"第一天我先给JchatMind定义了三个最简单的工具函数,并把它们放进一个字典:def get_current_time(): import datetime return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def calculate(expression: str): try: return str(eval(expression, {"__builtins__": {}}, {})) except Exception as e: return f"计算错误: {e}" def get_weather(city: str): fake_weather = {"北京": "晴,22度", "上海": "多云,25度", "广州": "小雨,28度"} return fake_weather.get(city, f"没有{city}的天气数据") TOOLS = { "get_current_time": {"desc": "获取当前日期和时间,无参数", "func": get_current_time}, "calculate": {"desc": "计算数学表达式,参数expression为字符串", "func": calculate}, "get_weather": {"desc": "查询城市天气,参数city为城市名", "func": get_weather}, }注意,这里calculate用了eval,只是为了第一天演示方便,生产环境绝不能这么干,会被注入恶意代码。后面我会换成语法树解析。5.3 让模型学会"开口要工具"我不想接复杂的function calling协议,第一天的做法非常原始:把工具说明写进system prompt,并严格要求模型按固定JSON格式输出。TOOL_PROMPT = """ 你可以使用以下工具: - get_current_time: 获取当前日期和时间,无参数 - calculate: 计算数学表达式,参数 expression: string - get_weather: 查询城市天气,参数 city: string 如果需要使用工具,请严格输出如下JSON格式(不要输出任何其他文字): {"tool": "工具名", "args": {"参数名": "参数值"}} 如果没有合适的工具,直接正常回答用户。 """调用的时候,把TOOL_PROMPT拼在system消息后面:messages = [ {"role": "system", "content": BASE_SYSTEM + "\n" + TOOL_PROMPT}, {"role": "user", "content": "现在几点了?"}, ] reply = chat_once(messages, temperature=0.1) print(reply)温度我特意调低到0.1,因为第一步只要求模型输出稳定的JSON格式,不能让它的"创作欲望"捣乱。5.4 解析JSON指令、执行函数、把结果"回喂"给模型下面这段是整个工具调用骨架最关键的部分:import json def run_tool_once(messages): reply = chat_once(messages, temperature=0.1) try: tool_call = json.loads(reply) tool_name = tool_call["tool"] tool_args = tool_call.get("args", {}) if tool_name in TOOLS: result = TOOLS[tool_name]["func"](**tool_args) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "user", "content": f"工具执行结果是: {result}。请基于这个结果回答用户的原问题。"}) return chat_once(messages, temperature=0.3) except json.JSONDecodeError: # 模型没输出JSON,说明它选择了直接回答 return reply return reply流程展开就是四步:调用模型,得到回复文本。尝试把回复解析成JSON,看看是不是工具调用指令。如果解析成功,按名字找到工具函数,实参传进去,拿到真实结果。把工具执行结果作为一条user消息追加到上下文里,再次调用模型,让它基于真实结果组织最终回复。这一步做完,JchatMind第一次"动手做了事"。它不再是纯嘴炮,至少能报时间、算算术、报天气了。实现完之后在终端试了一下,输入"北京天气怎么样",程序输出的链路一清二楚:模型回复(第一轮): {"tool": "get_weather", "args": {"city": "北京"}} 工具执行结果: 晴,22度 JchatMind最终回复: 北京今天是晴天,气温22度,适合出门。5.5 这个极简设计的边界在哪里我必须诚实地说,这个版本的"工具调用"非常脆弱。比如:模型输出JSON时带了任何多余文字,json.loads就会失败。多轮工具调用无法自动接力,比如"算一下今天到年底有多少天"需要先取日期再计算,链式调用没做。工具函数如果内部抛异常,处理得还很粗糙。但这些不足是正常的。第一天的目标是打通"模型出指令—程序执行—结果回喂"这条链路,不是做工业级产品。有了这条主干道,后面往上面加"链式调用""稳健解析""异常恢复"才有地方挂。6. 第一天踩过的三个坑:完整的排查链路写代码不可能不踩坑。这一节我把第一天里印象最深、也最具代表性的三个问题完整还原,包括现象、排查过程和最后的修复方案。这些坑都是"不亲手从底层写一遍,很难碰到的经验"。6.1 流式输出解析错位:整个data块被当成一段JSON解析现象:调用chat_stream时,输出了一段完整的JSON解析错误堆栈。仔细看,内容是"Expected value: line 1 column 1",后面跟了一整段包含data:前缀的文本。排查过程:我先在iter_lines里打印每一行原始内容,看到形如data: {"choices": [{"delta": {"content": "你"}}]}这样的行。最初我直接line[5:]然后json.loads,没有考虑这一行前面可能还有连续两个换行符导致的空行,也没考虑[DONE]标记。调试发现,最后的data: [DONE]被json.loads解析,直接抛异常。修复:把"判断[DONE]"放到json.loads之前,并且用strip()去掉空白。if line.startswith("data:"): data_str = line[5:].strip() if data_str == "[DONE]": break chunk = json.loads(data_str)这个坑的本质,是我对流式协议(SSE)的边界格式不熟。框架把这一切都遮住了,所以我从不后悔第一天从底层开始。6.2 上下文膨胀导致"答非所问"和请求超时现象:连续对话20轮后,模型回答开始变慢,更严重的是会"跑题",有时候甚至重复之前的回答。排查过程:我先打印了每次请求的messages长度和usage.total_tokens,发现20轮之后messages已经堆了超过6000个token。大模型读入的上下文越长,注意力被稀释得越严重。模型并不是无限聪明,它只是"在给定的一段文本内接续",如果这段文本里有太多无关紧要的历史,核心问题反而被淹没。另外请求体变大导致网络传输和模型处理时间变长,终端表现就是"卡顿"。修复:加了trim_messages截断函数,保留system和最近10轮。并通过在system里加一句"只依据最近的对话内容回答,忽略与当前问题无关的早期内容",让模型主动聚焦。这个坑让我意识到:记忆管理不是一个"存下来"就完事的问题,而是一个动态的、有损的压缩过程。智能体必须学会"忘掉"不重要的东西,和人是类似的。6.3 温度参数导致的"工具调用不稳定"现象:工具调用模块偶尔好用,偶尔输出一堆散文,完全不按JSON格式来。代码里明明已经写清楚了"不要输出任何其他文字"。排查过程:我打印了temperature的值,发现一开始用的0.7。温度高意味着随机性大,模型在"守规矩输出JSON"和"自由发挥"之间摇摆。同样一条用户问题,temperature=0.1时稳如老狗,temperature=0.7时就开始"发挥"。我又试了把工具说明从system里挪到一条单独的user消息里,效果也不如直接拼在system里稳定。修复:工具调用分支用temperature=0.1,日常对话分支用0.3~0.7,不同场景拆成不同的调用路径。这里总结了一条经验:想让模型做"结构化输出",就得牺牲掉一部分"创造力"。没有参数能两全,只有根据场景做取舍。下面把三个坑汇总成一张表,方便你对照:问题现象根因修复流式解析错位抛JSON解析异常没处理[DONE]和多行间隔先判断[DONE],再parse上下文膨胀答非所问、请求变慢messages无限增长,token超量截断+引导模型聚焦温度过高工具调用不稳定随机性压过了格式约束工具分支降低temperature7. 第一天的成果,和JchatMind接下来的路一天写下来,JchatMind拥有的其实就三样东西:一个能通过HTTP协议跟大模型通信的Python函数;一个能维持多轮对话的messages循环;一个能触发本地工具并回喂结果的极简工具调用骨架。代码量不多,但每一步都是亲手敲出来的,每一行都知道它在干什么。我特别想说的是,做完这些之后回头看,那些框架里"顺手一点就有的功能",在这里都变成了一个个需要自己决策的细节。比如:system prompt怎么设计,才能既约束格式又不压抑个性?上下文保留多少轮,才能兼顾记忆和成本?工具调用失败,是重新让模型选一次,还是直接告诉用户"做不到"?温度、max_tokens这些参数,在不同分支应该用什么值?这些问题,没有标准答案,只能靠实践和积累。接下来我会继续往下走。脑子里已经列了几个方向:一是把上下文从"截断"升级成"按token数裁剪",加摘要压缩;二是把工具调用升级成可靠的function calling协议,让模型原生支持结构化调用;三是给JchatMind加一个简易的记忆持久化,让它在重启后还能记住用户偏好。每一步都会在同一套代码上迭代,继续以"从底层做起"的方式展开这个系列。第一天的JchatMind还很简陋,但地基已经切切实实落地了。下一篇文章,我会开始处理上下文管理的升级——这也是智能体从"demo"走向"能真正连续工作"的关键一步。如果你也在做类似的事,欢迎参考这套思路,从最原始的那一次HTTP调用开始,亲手给你的智能体打好底座。
先说明一下:这个系列我在做的时候,给自己立了一条规矩——不动用任何现成的智能体框架,不引入任何封装好的SDK,完全从最底层、最原始的大模型调用开始写。很多人做智能体,第一反应就是装框架、拖组件、接插件,结果做了半天,连"模型到底是怎么被调起来的"都说不清楚。这不是坏事,但出了问题也很难排查。所以我决定把这个项目直接摊开来写,第一天就老实交代:JchatMind智能体最初的样子,其实就是一段发往大模型API的HTTP请求,外加一个循环读取返回内容的终端脚本。这篇就是给你完整还原这个过程,包括我从零开始写的每一步代码、踩过的每个坑,以及为什么这么设计。这个系列适合两类人看:一类是刚接触大模型应用开发、想弄清楚"智能体到底由什么组成"的初学者;另一类是已经用过各种框架、但想回头把地基夯实,遇到问题不至于抓瞎的开发者。1. 先讲清楚:JchatMind的第一天,为什么非要"退回到原始时代"很多教程会教你直接上手某个框架,把大模型的调用封装成一行函数。但我的观点很朴素:你要做一个智能体,首先得知道一次"大模型调用"在机器层面上究竟发生了什么,否则后面所有的记忆管理、工具调用、流程编排,都是踩在沙子上盖楼。1.1 智能体的地基,其实只有三件事我理解的智能体,剥掉所有花哨概念后只剩下三块:模型调用:让大模型根据输入文本生成输出文本。上下文管理:把"之前的聊天内容"作为输入的一部分,让模型看起来"记住了"对话。工具调用:让模型在需要的时候,输出一个"我要执行某个函数"的指令,程序去执行,再把结果喂回给模型。这三件事,没有任何一件是非要用框架才能完成的。第一天的JchatMind,我要求自己连第一步"模型调用"都必须能徒手写出来——精确到HTTP请求长什么样、响应怎么解析。1.2 框架隐藏了太多"本该知道"的细节举个例子,很多人第一次用封装好的大模型库,只是传了一个prompt就拿到了结果。但模型到底是怎么"看懂"system message的?temperature调到0.9和0.2有什么差别?为什么返回内容有时候是断裂的?流式输出时data: [DONE]到底代表什么?这些细节,框架不会替你想,但它们恰恰决定了智能体的稳定性。从原始调用开始,并不是为了炫技,而是为了给后续排查问题留一条清晰的逻辑链。我第一天踩的几个坑,后面会挨个展开,全部都是底层细节没吃透导致的。1.3 什么时候才轮到框架出场我也不是在否定框架。当项目跑到一定规模——要对接多个模型、要编排复杂工作流、要接入外部知识库——这时候引入框架节约成本是合理的。但前提是你已经对"一次裸调用"了如指掌。这样框架出问题的时候,你能判断出是框架的问题,还是模型本身的问题。第一天,我先把这层底子打牢。2. "最原始调用"到底长什么样:一次HTTP请求的内在结构先建立一个认知:目前主流的大模型API,本质上就是一个HTTP POST接口。你把一段文本通过JSON格式发过去,服务端把模型生成的结果通过JSON返回来。看起来高深莫测,拆开其实非常直白。2.1 请求体里的每个字段,都是什么意思大模型API的请求地址,通常形如:POST /v1/chat/completions请求头需要两个东西:Content-Type: application/json,以及Authorization: Bearer 你的API密钥。请求体核心字段大概就这几个:字段作用我的建议model指定用哪个模型根据任务选,对话类选通用旗舰,结构化任务选支持严格JSON输出的messages对话消息列表,包含角色和内容核心字段,智能体的所有记忆都体现在这里temperature控制随机性,0到1或更高写代码用0.2以下,创意写作可以0.7以上max_tokens限制本次最大生成长度不设可能超出预期,设了能控制成本stream是否流式返回做对话产品建议true,做批量处理建议false2.2 messages数组:智能体"记忆"的真相这个设计很多人第一次接触会懵。它不是只把"用户最新一句话"发给模型,而是要发给模型一个数组:[ {"role": "system", "content": "你是一个智能助手,名字叫JchatMind。"}, {"role": "user", "content": "你好,请介绍一下自己"}, {"role": "assistant", "content": "你好,我是JchatMind,一个智能体助手。"}, {"role": "user", "content": "你刚才说你叫什么?"} ]用大白话讲,你把对话历史整个摊开在模型面前,模型"读"一遍,然后接着往下写。它没有真正记住任何东西,它只是每次都在"看完聊天记录后接着回应"。这带来两个很重要的推论:上下文越长,花的token越多,速度越慢。程序如果把无关紧要的历史全塞进去,模型的表现会被稀释。2.3 角色分工:system、user、assistant分别管什么这三个角色,相当于一个团队的三种分工:system:给模型定基调、立规矩。它是"幕后的导演",用户看不到但对行为影响最大。user:用户的发言。assistant:模型自己的历史回复。在连续对话中必须把它带上,模型才知道"自己之前说过什么"。我在第一天犯过一个典型错误:连续对话没带assistant的历史回复,导致模型每次回答都像失忆一样。后面排查到原因,才真正想通这个数组的设计逻辑。3. 不借助任何SDK,用curl把第一句话送给模型理论说完了,直接动手。第一步我不写代码,先用最原始的工具——curl——来验证模型接口通不通。这个过程不需要任何依赖,是排查连通性最快的方法。3.1 准备环境变量,别把密钥写死在代码里我在项目根目录建了一个.env文件,把API密钥和模型名放进去:export JCHAT_API_KEY="你的密钥" export JCHAT_MODEL="your-model-name" export JCHAT_API_BASE="https://your-api-endpoint.example.com/v1/chat/completions"把密钥放到环境变量里是个好习惯。不然代码写错提交到仓库,密钥就泄露了。后面写Python时也需要读取这两个变量。3.2 真正发出去的第一条请求在终端执行:curl "$JCHAT_API_BASE" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $JCHAT_API_KEY" \ -d '{ "model": "'"$JCHAT_MODEL"'", "messages": [ {"role": "system", "content": "你是JchatMind,一个简洁、准确的智能助手。"}, {"role": "user", "content": "请用一句话说明你的身份。"} ], "temperature": 0.3, "max_tokens": 200 }'等几秒,会看到返回的JSON长这样:{ "id": "chatcmpl-xxx", "object": "chat.completion", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "我是JchatMind,一个由简化流程驱动的智能助手。" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 28, "completion_tokens": 15, "total_tokens": 43 } }看到这个返回,说明最底层的通信已经打通了。我把这个时刻视为JchatMind的"开机自检"。3.3 响应字段逐个拆解choices[0].message.content:模型生成的正文,这是主角。choices[0].finish_reason:为什么结束。stop表示自然结束,length表示触达max_tokens上限,tool_calls表示模型想调用工具。usage.total_tokens:本次调用消耗的token总量。做成本优化的时候盯这一个字段就够了。有个坑值得提前说:如果你收到的content是null,但finish_reason是tool_calls,说明模型没有直接回答,而是想调用工具。这种返回值我第一天没处理,直接解析content就报了空指针,后面专门写了工具调用模块才解决。4. 用代码完成"对话闭环":从一次请求到连续对话curl能通,说明地基可以开工了。但JchatMind不能靠手敲curl活着,我需要把它变成可复用的Python代码。这个阶段,我不装任何第三方大模型SDK,只依赖requests库。4.1 最小的调用函数:入参是消息数组,出参是模型回复import os import json import requests API_BASE = os.getenv("JCHAT_API_BASE") API_KEY = os.getenv("JCHAT_API_KEY") MODEL_NAME = os.getenv("JCHAT_MODEL") def chat_once(messages, temperature=0.3, max_tokens=1024): headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", } payload = { "model": MODEL_NAME, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } resp = requests.post(API_BASE, headers=headers, json=payload, timeout=60) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"]这个函数非常简单,但它划出了整条链路的边界:入参是message数组,出参是模型文本。后续所有智能体能力——记忆、工具调用、流程控制——都在这个函数外面加逻辑,不会污染它的本质。4.2 连续对话的关键:把每轮内容都追加到messages里我一开始天真地以为,连续对话就是把用户的新问题追加进数组。结果模型不但忘记了自己说过什么,连用户第一句话都记不住。原因是:我只追加了user消息,没把assistant的回复也追加进去。正确做法是这样:messages = [ {"role": "system", "content": "你是JchatMind,回复简洁准确,不编造信息。"}, ] while True: user_input = input("你: ") if user_input.lower() in ("exit", "quit"): break messages.append({"role": "user", "content": user_input}) reply = chat_once(messages) print(f"JchatMind: {reply}") messages.append({"role": "assistant", "content": reply})这段代码就是JchatMind第一天的"对话闭环":输入、调用、输出、回填历史。4.3 上下文无限膨胀的隐患跑着跑着我发现一个新问题:对话越长,每次请求越慢,费用越高。原因很简单——messages被撑得越来越大。模型每次都要把全部历史重新读一遍,token成本线性上涨。第一天的简陋策略是截断:def trim_messages(messages, max_messages=20): if len(messages) <= max_messages: return messages return [messages[0]] + messages[-(max_messages - 1):]默认保留system消息,然后只保留最近若干轮对话。这个策略很粗糙,但它让我理解了:智能体的记忆管理,本质上是一个"在信息和成本之间做取舍"的动态过程。后续版本会换成token数裁剪,但第一天的思路是正确的。4.4 流式输出:让模型"边说边吐"而不是"憋完才说"对话产品不做流式输出,体验会很差。用户发一句话,页面要转圈好几秒才一次性出结果。流式输出是让模型生成一段就传一段,终端或前端就能边收边显示。启用流式很简单,把stream设为true:def chat_stream(messages, temperature=0.3): headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", } payload = { "model": MODEL_NAME, "messages": messages, "temperature": temperature, "stream": True, } with requests.post(API_BASE, headers=headers, json=payload, stream=True, timeout=120) as resp: resp.raise_for_status() for line in resp.iter_lines(): if line: line = line.decode("utf-8") if line.startswith("data:"): data_str = line[5:].strip() if data_str == "[DONE]": break chunk = json.loads(data_str) delta = chunk["choices"][0]["delta"].get("content", "") if delta: print(delta, end="", flush=True)这段代码的核心是处理SSE(Server-Sent Events)流。每一行以data:开头,后面跟着一段JSON。[DONE]是结束标记。我第一天在这里栽了个跟头,后面会详细说。5. 给JchatMind装上"手和眼睛":最原始版本的工具调用设计对话闭环打通之后,JchatMind已经能"聊"了。但智能体和聊天机器人的最大区别在于:智能体能调用外部工具,改变真实世界的状态。比如查天气、算数学、读写文件。第一天我决定做一个极其粗糙但逻辑完整的工具调用原型。5.1 工具调用的本质:模型只出"指令",程序来执行很多人误以为大模型调用工具是"模型直接联网执行操作"。实际上模型根本不会执行任何东西,它只是输出一段结构化文本,描述"我建议调用哪个函数、传什么参数"。真正动手的是你的程序。打个比方:模型是个"只会发号施令的顾问",它写一张便签说"请给我拿一杯水,温度70度",你作为执行者读便签、去倒水、把水端回来,再告诉顾问"水来了"。整个过程顾问并没有真实接触到水。5.2 用一个字典管理可用的"工具"第一天我先给JchatMind定义了三个最简单的工具函数,并把它们放进一个字典:def get_current_time(): import datetime return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def calculate(expression: str): try: return str(eval(expression, {"__builtins__": {}}, {})) except Exception as e: return f"计算错误: {e}" def get_weather(city: str): fake_weather = {"北京": "晴,22度", "上海": "多云,25度", "广州": "小雨,28度"} return fake_weather.get(city, f"没有{city}的天气数据") TOOLS = { "get_current_time": {"desc": "获取当前日期和时间,无参数", "func": get_current_time}, "calculate": {"desc": "计算数学表达式,参数expression为字符串", "func": calculate}, "get_weather": {"desc": "查询城市天气,参数city为城市名", "func": get_weather}, }注意,这里calculate用了eval,只是为了第一天演示方便,生产环境绝不能这么干,会被注入恶意代码。后面我会换成语法树解析。5.3 让模型学会"开口要工具"我不想接复杂的function calling协议,第一天的做法非常原始:把工具说明写进system prompt,并严格要求模型按固定JSON格式输出。TOOL_PROMPT = """ 你可以使用以下工具: - get_current_time: 获取当前日期和时间,无参数 - calculate: 计算数学表达式,参数 expression: string - get_weather: 查询城市天气,参数 city: string 如果需要使用工具,请严格输出如下JSON格式(不要输出任何其他文字): {"tool": "工具名", "args": {"参数名": "参数值"}} 如果没有合适的工具,直接正常回答用户。 """调用的时候,把TOOL_PROMPT拼在system消息后面:messages = [ {"role": "system", "content": BASE_SYSTEM + "\n" + TOOL_PROMPT}, {"role": "user", "content": "现在几点了?"}, ] reply = chat_once(messages, temperature=0.1) print(reply)温度我特意调低到0.1,因为第一步只要求模型输出稳定的JSON格式,不能让它的"创作欲望"捣乱。5.4 解析JSON指令、执行函数、把结果"回喂"给模型下面这段是整个工具调用骨架最关键的部分:import json def run_tool_once(messages): reply = chat_once(messages, temperature=0.1) try: tool_call = json.loads(reply) tool_name = tool_call["tool"] tool_args = tool_call.get("args", {}) if tool_name in TOOLS: result = TOOLS[tool_name]["func"](**tool_args) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "user", "content": f"工具执行结果是: {result}。请基于这个结果回答用户的原问题。"}) return chat_once(messages, temperature=0.3) except json.JSONDecodeError: # 模型没输出JSON,说明它选择了直接回答 return reply return reply流程展开就是四步:调用模型,得到回复文本。尝试把回复解析成JSON,看看是不是工具调用指令。如果解析成功,按名字找到工具函数,实参传进去,拿到真实结果。把工具执行结果作为一条user消息追加到上下文里,再次调用模型,让它基于真实结果组织最终回复。这一步做完,JchatMind第一次"动手做了事"。它不再是纯嘴炮,至少能报时间、算算术、报天气了。实现完之后在终端试了一下,输入"北京天气怎么样",程序输出的链路一清二楚:模型回复(第一轮): {"tool": "get_weather", "args": {"city": "北京"}} 工具执行结果: 晴,22度 JchatMind最终回复: 北京今天是晴天,气温22度,适合出门。5.5 这个极简设计的边界在哪里我必须诚实地说,这个版本的"工具调用"非常脆弱。比如:模型输出JSON时带了任何多余文字,json.loads就会失败。多轮工具调用无法自动接力,比如"算一下今天到年底有多少天"需要先取日期再计算,链式调用没做。工具函数如果内部抛异常,处理得还很粗糙。但这些不足是正常的。第一天的目标是打通"模型出指令—程序执行—结果回喂"这条链路,不是做工业级产品。有了这条主干道,后面往上面加"链式调用""稳健解析""异常恢复"才有地方挂。6. 第一天踩过的三个坑:完整的排查链路写代码不可能不踩坑。这一节我把第一天里印象最深、也最具代表性的三个问题完整还原,包括现象、排查过程和最后的修复方案。这些坑都是"不亲手从底层写一遍,很难碰到的经验"。6.1 流式输出解析错位:整个data块被当成一段JSON解析现象:调用chat_stream时,输出了一段完整的JSON解析错误堆栈。仔细看,内容是"Expected value: line 1 column 1",后面跟了一整段包含data:前缀的文本。排查过程:我先在iter_lines里打印每一行原始内容,看到形如data: {"choices": [{"delta": {"content": "你"}}]}这样的行。最初我直接line[5:]然后json.loads,没有考虑这一行前面可能还有连续两个换行符导致的空行,也没考虑[DONE]标记。调试发现,最后的data: [DONE]被json.loads解析,直接抛异常。修复:把"判断[DONE]"放到json.loads之前,并且用strip()去掉空白。if line.startswith("data:"): data_str = line[5:].strip() if data_str == "[DONE]": break chunk = json.loads(data_str)这个坑的本质,是我对流式协议(SSE)的边界格式不熟。框架把这一切都遮住了,所以我从不后悔第一天从底层开始。6.2 上下文膨胀导致"答非所问"和请求超时现象:连续对话20轮后,模型回答开始变慢,更严重的是会"跑题",有时候甚至重复之前的回答。排查过程:我先打印了每次请求的messages长度和usage.total_tokens,发现20轮之后messages已经堆了超过6000个token。大模型读入的上下文越长,注意力被稀释得越严重。模型并不是无限聪明,它只是"在给定的一段文本内接续",如果这段文本里有太多无关紧要的历史,核心问题反而被淹没。另外请求体变大导致网络传输和模型处理时间变长,终端表现就是"卡顿"。修复:加了trim_messages截断函数,保留system和最近10轮。并通过在system里加一句"只依据最近的对话内容回答,忽略与当前问题无关的早期内容",让模型主动聚焦。这个坑让我意识到:记忆管理不是一个"存下来"就完事的问题,而是一个动态的、有损的压缩过程。智能体必须学会"忘掉"不重要的东西,和人是类似的。6.3 温度参数导致的"工具调用不稳定"现象:工具调用模块偶尔好用,偶尔输出一堆散文,完全不按JSON格式来。代码里明明已经写清楚了"不要输出任何其他文字"。排查过程:我打印了temperature的值,发现一开始用的0.7。温度高意味着随机性大,模型在"守规矩输出JSON"和"自由发挥"之间摇摆。同样一条用户问题,temperature=0.1时稳如老狗,temperature=0.7时就开始"发挥"。我又试了把工具说明从system里挪到一条单独的user消息里,效果也不如直接拼在system里稳定。修复:工具调用分支用temperature=0.1,日常对话分支用0.3~0.7,不同场景拆成不同的调用路径。这里总结了一条经验:想让模型做"结构化输出",就得牺牲掉一部分"创造力"。没有参数能两全,只有根据场景做取舍。下面把三个坑汇总成一张表,方便你对照:问题现象根因修复流式解析错位抛JSON解析异常没处理[DONE]和多行间隔先判断[DONE],再parse上下文膨胀答非所问、请求变慢messages无限增长,token超量截断+引导模型聚焦温度过高工具调用不稳定随机性压过了格式约束工具分支降低temperature7. 第一天的成果,和JchatMind接下来的路一天写下来,JchatMind拥有的其实就三样东西:一个能通过HTTP协议跟大模型通信的Python函数;一个能维持多轮对话的messages循环;一个能触发本地工具并回喂结果的极简工具调用骨架。代码量不多,但每一步都是亲手敲出来的,每一行都知道它在干什么。我特别想说的是,做完这些之后回头看,那些框架里"顺手一点就有的功能",在这里都变成了一个个需要自己决策的细节。比如:system prompt怎么设计,才能既约束格式又不压抑个性?上下文保留多少轮,才能兼顾记忆和成本?工具调用失败,是重新让模型选一次,还是直接告诉用户"做不到"?温度、max_tokens这些参数,在不同分支应该用什么值?这些问题,没有标准答案,只能靠实践和积累。接下来我会继续往下走。脑子里已经列了几个方向:一是把上下文从"截断"升级成"按token数裁剪",加摘要压缩;二是把工具调用升级成可靠的function calling协议,让模型原生支持结构化调用;三是给JchatMind加一个简易的记忆持久化,让它在重启后还能记住用户偏好。每一步都会在同一套代码上迭代,继续以"从底层做起"的方式展开这个系列。第一天的JchatMind还很简陋,但地基已经切切实实落地了。下一篇文章,我会开始处理上下文管理的升级——这也是智能体从"demo"走向"能真正连续工作"的关键一步。如果你也在做类似的事,欢迎参考这套思路,从最原始的那一次HTTP调用开始,亲手给你的智能体打好底座。