多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从零构建AI Agent:基于Ollama与DeepSeek的ReAct循环实战

从零构建AI Agent:基于Ollama与DeepSeek的ReAct循环实战 1. 为什么我要自己从零搭一个Agent先说结论市面上Agent框架我几乎试了个遍LangChain、AutoGPT、MetaGPT、CrewAI甚至一些国内团队做的工作流引擎最后我还是决定自己从零写一个。原因不复杂——框架帮你省掉的那点胶水代码远远抵不过它给你带来的调试噩梦。你永远不知道它内部哪一步偷偷改了prompt哪一步把工具调用的返回值截断了出了问题只能翻源码翻完发现是某个抽象层把异常吞了。这个项目就是在这种背景下启动的。目标很明确用最少的依赖构建一个能跑通“感知-思考-行动-观察”完整闭环的Agent底层大模型用Ollama本地部署保证离线可用同时预留API接口方便切换到DeepSeek这类云端模型。整个项目不依赖任何重型框架核心代码控制在几百行以内每一行你都能看懂、能改、能调。适合谁来参考如果你已经用过大模型API知道什么是prompt、什么是token但每次想做个稍微复杂点的自动化任务就被框架的抽象层绕晕那这篇内容就是写给你的。如果你完全没接触过大模型也没关系我会把每个环节的原理和操作都拆开讲你照着做也能跑起来。我踩过的坑包括但不限于Ollama模型存储路径默认在C盘导致磁盘爆满、DeepSeek API的context length报错、工具调用返回格式不一致导致解析失败、Agent陷入死循环疯狂烧token。这些问题的解决方案我都会在对应章节里详细展开。2. 整体架构设计与技术选型思路2.1 为什么选Ollama做本地推理底座本地跑大模型这件事2024年之前还挺折腾的要自己编译llama.cpp、转换模型格式、配CUDA环境。Ollama出来之后基本上一条命令就能跑起来。我选它作为本地推理底座核心原因有三个第一模型管理足够简单。ollama pull qwen2.5:7b就能把模型拉下来ollama run qwen2.5:7b就能对话。不需要关心GGUF格式转换、不需要手动指定GPU层数它自己会检测硬件并做最优分配。第二API兼容OpenAI格式。Ollama默认在11434端口暴露一个HTTP服务/v1/chat/completions这个端点的请求体和响应体跟OpenAI的格式基本一致。这意味着我的Agent代码只需要写一套HTTP调用逻辑改个base_url就能在Ollama和DeepSeek之间切换。第三离线可用。这是我最看重的。有些场景下网络不稳定或者数据不能出本地Ollama拉完模型之后完全离线运行不依赖任何外部服务。但Ollama也不是没有坑。最典型的就是模型存储路径问题。默认情况下Linux下模型存在/usr/share/ollama/.ollama/modelsWindows下存在C:\Users\用户名\.ollama\models。如果你跟我一样C盘只有128G的固态拉两个7B模型就红了。解决办法是通过环境变量OLLAMA_MODELS指定到其他盘# Linux/Mac export OLLAMA_MODELS/data/ollama/models # Windows (PowerShell) $env:OLLAMA_MODELS D:\ollama\models设置完之后重启Ollama服务之前拉过的模型需要重新拉一遍因为路径变了它找不到旧文件。这个操作建议在刚开始部署的时候就做别等拉了一堆模型再迁移虽然可以手动拷贝但容易出权限问题。2.2 Agent核心循环的设计取舍一个Agent最核心的东西就是它的主循环。我用的是最经典的ReAct模式的变体流程如下接收用户输入拼接到系统prompt里调用大模型获取输出解析输出判断是“最终回答”还是“工具调用”如果是工具调用执行工具把结果拼回上下文回到第2步直到模型给出最终回答或达到最大轮次这个循环看起来简单但每个环节都有设计决策要做。决策一输出格式用JSON还是自然语言标记我试过两种方案。JSON方案是让模型输出{action: search, action_input: xxx}这样的结构解析起来很稳但模型有时候会在JSON外面包一层markdown代码块或者漏掉引号。自然语言标记方案是让模型输出Action: search\nAction Input: xxx解析用正则容错性更好但模型有时候会自己编格式。最后我选了JSON方案容错解析因为DeepSeek和Qwen2.5对JSON输出的遵循度都不错而且JSON更容易扩展字段。决策二工具调用的结果怎么拼回上下文最简单的方式是直接把工具返回的原始文本塞进去。但这里有个坑如果工具返回的内容很长比如搜索返回了10条结果上下文会迅速膨胀token消耗飞快。我的做法是对工具返回结果做截断和摘要超过一定长度的内容只保留前N个字符或者调用一次模型做摘要。这个策略在config.yaml里可以配。决策三最大轮次设多少我一开始设了20轮结果有一次Agent陷入死循环20轮烧了我将近5万token。后来改成默认8轮可配置并且加了重复检测——如果连续两轮的工具调用和参数完全一样直接中断并返回错误。这个机制救了我很多次。2.3 模型选型本地Ollama还是云端API这是个绕不开的问题。我的方案是两者都支持通过配置切换。具体对比如下维度Ollama本地DeepSeek API成本电费硬件按token计费延迟取决于硬件7B模型在RTX 3060上约20token/s网络延迟排队通常更快隐私完全本地数据出本地模型能力7B-14B为主复杂推理较弱强适合复杂任务离线支持不支持我的实际用法是开发调试阶段用Ollama因为频繁调用不花钱改prompt、调工具可以随便试生产环境或者复杂任务用DeepSeek API因为推理能力强很多尤其是多步工具调用的时候7B模型经常犯迷糊。切换逻辑很简单在配置文件里写llm: provider: ollama # 或 deepseek ollama: base_url: http://localhost:11434/v1 model: qwen2.5:7b deepseek: base_url: https://api.deepseek.com/v1 model: deepseek-chat api_key: your-key-here代码里根据provider字段决定用哪个配置。这里有个细节Ollama的API不需要api_key但OpenAI的SDK要求这个字段不能为空所以随便填一个字符串就行比如ollama。3. 核心模块拆解与实操要点3.1 环境准备与Ollama部署避坑指南先把基础环境搭起来。我假设你用的是Linux或者WSLWindows原生也行但路径处理会多一些麻烦。第一步安装Ollama。curl -fsSL https://ollama.com/install.sh | sh安装完之后ollama --version确认一下。如果下载速度慢这是正常现象模型文件动辄几个G建议挂个下载工具或者错峰下载。第二步修改模型存储路径强烈建议。sudo mkdir -p /data/ollama/models sudo chown -R $USER:$USER /data/ollama/models export OLLAMA_MODELS/data/ollama/models把这个export写到~/.bashrc里否则重启终端就失效了。然后重启Ollama服务sudo systemctl restart ollama第三步拉取模型。ollama pull qwen2.5:7b为什么选Qwen2.5而不是Llama因为在中文场景下Qwen2.5的表现明显更好而且它对工具调用的支持比较规范。7B版本在16G内存的机器上就能跑如果显存够大可以上14B。第四步验证API可用。curl http://localhost:11434/v1/chat/completions \ -H Content-Type: application/json \ -d { model: qwen2.5:7b, messages: [{role: user, content: 你好}] }能返回JSON就说明服务正常。注意Ollama默认只监听127.0.0.1如果你想让局域网内其他机器访问需要设置OLLAMA_HOST0.0.0.0。但这样会带来安全风险建议只在可信网络内这么做。3.2 工具系统的设计与实现Agent之所以是Agent而不是聊天机器人核心就在于它能调用工具。我的工具系统设计遵循三个原则原则一工具描述要足够详细。模型是根据工具的描述来决定调不调的。如果你只写“搜索工具”模型可能不知道什么时候该用。我通常会写清楚这个工具做什么、什么时候用、参数是什么格式、返回什么。比如{ name: web_search, description: 当需要获取实时信息、新闻、或者你不确定的事实性知识时使用此工具。输入应该是一个搜索查询字符串。, parameters: { type: object, properties: { query: { type: string, description: 搜索关键词尽量具体 } }, required: [query] } }原则二工具执行要有超时和异常处理。我见过太多Agent因为一个HTTP请求卡住导致整个流程挂掉。每个工具调用都包一层try-except设置超时时间返回结构化的错误信息而不是直接抛异常。原则三工具返回结果要格式化。不要直接把原始JSON丢给模型模型解析起来费劲。我通常会把结果转成自然语言描述或者至少是简洁的文本格式。目前我内置了四个工具web_search调用搜索API获取实时信息read_file读取本地文件内容write_file写入内容到本地文件execute_code执行Python代码片段这个要慎用后面会讲安全注意事项工具注册的代码大概长这样class ToolRegistry: def __init__(self): self.tools {} def register(self, name, func, description, parameters): self.tools[name] { function: func, schema: { name: name, description: description, parameters: parameters } } def execute(self, name, args): if name not in self.tools: return f错误未知工具 {name} try: return self.tools[name][function](**args) except Exception as e: return f工具执行出错{str(e)}3.3 Prompt工程让模型稳定输出结构化结果Prompt写得好不好直接决定Agent能不能跑起来。我在这上面花的时间比写代码还多。系统prompt的核心结构你是一个智能助手可以通过调用工具来完成任务。 你可以使用以下工具 {tool_descriptions} 输出格式要求 - 如果你需要调用工具输出JSON{action: 工具名, action_input: {...}} - 如果你已经得到最终答案输出JSON{action: final_answer, action_input: 你的回答} 注意事项 - 每次只输出一个JSON对象不要输出其他内容 - 不要编造工具名称 - 如果工具返回错误尝试其他方法或直接回答这个prompt看起来简单但有几个细节很关键。细节一明确说“只输出JSON”。如果不强调模型经常会在JSON前后加解释性文字导致解析失败。我甚至在prompt里加了“不要输出markdown代码块标记”因为有些模型习惯性地把JSON包在里。细节二给出输出示例。对于7B这种小模型光说格式要求它可能理解不到位给一两个具体示例效果会好很多。但示例不要太多否则会占用大量context。细节三处理“模型不按格式输出”的情况。即使prompt写得再好模型也有概率跑偏。我的做法是加一层格式修复如果JSON解析失败尝试用正则提取{...}部分再解析一次如果还失败就把模型的原始输出当作final_answer处理同时记录一条警告日志。def parse_model_output(text): # 尝试直接解析 try: return json.loads(text) except json.JSONDecodeError: pass # 尝试提取JSON块 match re.search(r\{.*\}, text, re.DOTALL) if match: try: return json.loads(match.group()) except json.JSONDecodeError: pass # 兜底当作最终回答 return {action: final_answer, action_input: text}这个兜底逻辑看起来粗糙但实际用下来非常有效。很多时候模型只是多输出了一句“好的我来帮你”核心JSON还是对的正则一提取就出来了。4. 完整实操流程从零跑通第一个Agent任务4.1 项目结构搭建先把目录结构定下来后面加功能不会乱agent-from-scratch/ ├── config.yaml # 配置文件 ├── main.py # 入口 ├── agent/ │ ├── __init__.py │ ├── core.py # Agent主循环 │ ├── llm.py # 大模型调用封装 │ ├── tools.py # 工具注册与执行 │ └── memory.py # 上下文管理 ├── tools/ │ ├── search.py # 搜索工具 │ └── file_ops.py # 文件操作工具 └── requirements.txt依赖很少核心就三个requests、pyyaml、rich用于终端输出美化。不需要LangChain不需要任何Agent框架。pip install requests pyyaml rich4.2 大模型调用层实现这一层负责跟Ollama或DeepSeek通信对外暴露一个统一的chat方法import requests import yaml class LLMClient: def __init__(self, config_pathconfig.yaml): with open(config_path) as f: cfg yaml.safe_load(f) self.provider cfg[llm][provider] self.cfg cfg[llm][self.provider] def chat(self, messages, temperature0.1): url f{self.cfg[base_url]}/chat/completions headers {Content-Type: application/json} if api_key in self.cfg: headers[Authorization] fBearer {self.cfg[api_key]} payload { model: self.cfg[model], messages: messages, temperature: temperature } resp requests.post(url, jsonpayload, headersheaders, timeout120) resp.raise_for_status() return resp.json()[choices][0][message][content]temperature设成0.1而不是0是因为完全为0有时候会导致输出过于死板0.1在保持稳定性的同时有一点点灵活性。这个值可以根据任务调整工具调用场景建议0.1以下创意生成场景可以到0.7。4.3 Agent主循环实现这是整个项目的核心我把它压缩到了不到100行class Agent: def __init__(self, llm, tools, max_turns8): self.llm llm self.tools tools self.max_turns max_turns self.history [] def run(self, user_input): self.history [{role: user, content: user_input}] system_prompt self._build_system_prompt() for turn in range(self.max_turns): messages [{role: system, content: system_prompt}] self.history output self.llm.chat(messages) parsed parse_model_output(output) if parsed[action] final_answer: return parsed[action_input] # 执行工具 tool_name parsed[action] tool_input parsed[action_input] result self.tools.execute(tool_name, tool_input) # 拼回上下文 self.history.append({role: assistant, content: output}) self.history.append({role: user, content: f工具返回{result}}) return 达到最大轮次任务未完成跑起来之后你可以这样测试agent Agent(llm, tools) result agent.run(帮我搜索一下今天有什么AI新闻然后总结成三点) print(result)如果一切正常你会看到Agent先调用web_search拿到结果后再调用一次模型做总结最后输出答案。整个过程在终端里可以用rich库打印出来方便观察每一步。4.4 上下文管理与token控制Agent跑多轮之后上下文会越来越长。如果不加控制很快就会撞上模型的context limit。DeepSeek的报错信息我见过很多次maximum context length is 1048576 tokens虽然这个数字很大但如果你把工具返回的全文都塞进去几轮就满了。我的策略是滑动窗口摘要保留最近N轮完整对话默认5轮更早的对话做一次摘要压缩成一段话工具返回结果超过2000字符的只保留前2000字符def manage_context(history, max_recent5): if len(history) max_recent * 2: return history old history[:-max_recent*2] recent history[-max_recent*2:] summary summarize(old) # 调用模型做摘要 return [{role: system, content: f之前的对话摘要{summary}}] recent摘要这一步会额外消耗token但比起把全文塞进去总体还是省的。而且摘要能让模型抓住重点避免被无关细节干扰。5. 常见问题与排查技巧实录5.1 模型不调用工具怎么办这是最常见的问题。你明明注册了工具模型就是不用直接自己编答案。原因通常有三个原因一工具描述不够清晰。模型不知道什么时候该用这个工具。解决办法是在description里明确写“当...时使用此工具”。原因二系统prompt没有强调工具的存在。有些模型需要你在prompt里反复强调“你可以使用工具”甚至给出使用示例。原因三模型能力不够。7B模型在工具调用上的表现确实不如大模型。如果换了prompt还是不行建议换14B或者直接用DeepSeek API。我实测下来Qwen2.5:7B在工具调用上的遵循度大约80%14B能到90%以上DeepSeek基本95%以上。这个数据供你参考。5.2 工具调用参数格式错误模型有时候会把参数格式搞错比如该传字符串的传了对象该传数组的传了字符串。我的处理方式是在工具执行层做参数校验和转换def execute(self, name, args): tool self.tools.get(name) if not tool: return f错误未知工具 {name} # 参数校验 schema tool[schema][parameters] for param in schema.get(required, []): if param not in args: return f错误缺少必需参数 {param} # 类型转换 for key, value in args.items(): if isinstance(value, (dict, list)): args[key] json.dumps(value, ensure_asciiFalse) return tool[function](**args)这样即使模型传了嵌套对象工具也能正常处理。5.3 Agent陷入死循环这个问题的破坏力最大因为会疯狂烧token。我的解决方案是双重保险保险一最大轮次限制。默认8轮超过就强制停止。保险二重复检测。记录最近3轮的(action, action_input)组合如果出现完全重复直接中断。def is_loop(history, window3): recent history[-window*2:] actions [] for msg in recent: if msg[role] assistant: parsed parse_model_output(msg[content]) actions.append((parsed[action], str(parsed[action_input]))) if len(actions) 2 and actions[-1] actions[-2]: return True return False这个检测逻辑很简单但实际拦截率很高。大部分死循环都是因为模型反复调用同一个工具同一个参数。5.4 常见问题速查表问题现象可能原因解决方法模型不调用工具描述不清/prompt未强调/模型能力不足优化description换更大模型JSON解析失败模型输出格式跑偏加正则提取兜底上下文超限工具返回内容过长截断摘要滑动窗口死循环模型反复调用同一工具最大轮次重复检测Ollama响应慢硬件不足/模型太大换小模型或用量化版API报401api_key未配置或错误检查配置文件工具执行超时网络问题或工具本身慢加timeout和重试提示所有配置项都放在config.yaml里不要硬编码在代码中。这样切换模型、调整参数不需要改代码也方便版本管理。6. 安全边界与扩展方向6.1 Agent安全必须注意的几个点自己写Agent最大的好处是可控但可控不等于安全。有几个坑我必须提醒你第一execute_code工具极其危险。如果你让模型执行任意Python代码它可能删文件、发网络请求、甚至执行系统命令。我的做法是默认禁用这个工具如果确实需要用沙箱环境比如Docker容器隔离执行并且限制可用的模块。第二文件操作要限制目录。read_file和write_file必须限制在指定目录内不能让模型访问系统文件。用os.path.realpath做路径规范化然后检查是否在允许的根目录下。第三API key不要硬编码。用环境变量或者单独的配置文件并且把配置文件加入.gitignore。我见过有人把key提交到公开仓库几分钟就被扫走了。第四工具调用要有审计日志。每次工具调用记录时间、工具名、参数、结果方便出问题回溯。这个日志不需要多复杂写到一个本地文件就行。6.2 后续可以怎么扩展这个基础版本跑通之后扩展方向很多。我列几个我实际在用的方向一接入更多模型。除了Ollama和DeepSeek还可以接智谱、Kimi、甚至本地部署的其他模型。只要API兼容OpenAI格式改个配置就行。方向二加记忆系统。目前每次对话都是独立的没有长期记忆。可以加一个向量数据库把历史对话存进去每次根据当前问题检索相关记忆。方向三多Agent协作。一个Agent负责规划一个负责执行一个负责检查。这个复杂度会上去但处理复杂任务时效果明显更好。方向四Web界面。目前是命令行交互可以用Gradio或者Streamlit快速搭一个Web界面方便非技术用户使用。方向五定时任务。让Agent定期执行某些任务比如每天早上搜索行业新闻并生成摘要。这个只需要加一个调度器就行。我个人在实际操作中的体会是从零构建Agent最大的价值不是省了多少钱或者多少代码而是你对整个系统的每一个环节都有完全的掌控力。出了问题你知道去哪找想改行为你知道改哪里。这种掌控感是任何框架都给不了的。框架适合快速验证想法但真正要落地到生产环境自己写一遍是值得的。最后再分享一个小技巧在开发阶段把每一轮的完整prompt和模型输出都打印到日志里。不要嫌烦这是排查问题最有效的手段。很多时候你以为模型没按预期工作一看日志发现是prompt拼接出了问题或者工具返回格式跟你想的不一样。这个习惯帮我省了无数个小时的调试时间。
返回列表