恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从零搭建AI工程:Agent、RAG与Prompt工程实战指南
首页
资讯中心
/
从零搭建AI工程:Agent、RAG与Prompt工程实战指南
从零搭建AI工程:Agent、RAG与Prompt工程实战指南
发布时间:2026/9/28 23:08:17
1. 从零搭建AI工程为什么不是调API而是做工程说实话很多朋友第一次接触AI开发都是从调接口开始的。Python里写几行代码pip装上OpenAI SDK填一个Key一条chat.completion发过去文本送回搞定。这当然也算AI应用但如果你真的想在这个领域长期做事——不管是想做产品、做内部工具、还是想转行进入这个赛道——迟早会发现调接口只是AI工程里最不值钱的那一层。正宗的做法至少应该包含这样几个层次你手里有什么模型它擅长什么、不擅长什么你的业务数据怎么喂给它、怎么召回你给它的工具怎么设计才能让它自己去调用而不出错用户问一句乱七八槽的话系统怎么过滤、怎么兜底最后是线上跑起来之后怎么观测它的行为怎么持续改进。这一整套链路才是AI工程。用ai-engineering-from-scratch这个标题去搜网上的资料几乎清一色是30分钟跑通ChatGPT5分钟接入文心一言。这就像把所有学烹饪的人引向泡方便面——你的确能填饱肚子但永远做不出一桌像样的菜。所以这篇博文我准备把从零搭建一个可落地、可迭代、有工程结构的AI系统的完整过程讲一遍不是为了炫技而是给你一条能复现、能扩展的路。这篇文章适合谁正在做AI应用开发但总觉得只会调接口的工程师想在企业内部搭AI能力但不知道怎么下手的技术负责人以及有编程基础、想系统入门AI工程方向的学习者。我会尽量把每一步的原理、操作、坑都展开不藏着掖着。2. 环境与选型先把地基打对省得后面楼塌2.1 项目结构设计一开始就别写成一个大py文件我给这个项目定的名字就叫ai-engineering-from-scratch下文简称AEFS。它可以是一个实验项目也可以是一个以后要逐步孵化为产品的骨架工程。项目一立项我就先把目录结构定义好宁可初期多花半小时也不想让后面所有代码都堆在一个文件里。ai-engineering-from-scratch/ ├── pyproject.toml ├── README.md ├── .env.example ├── configs/ │ ├── app.yaml │ └── prompts/ ├── src/ │ ├── __init__.py │ ├── core/ │ │ ├── llm.py # LLM统一接入层 │ │ ├── agent.py # Agent核心循环 │ │ ├── tools.py # 工具注册与管理 │ │ └── memory.py # 会话记忆模块 │ ├── retrievers/ # RAG相关 │ ├── eval/ # 评测工具集 │ └── server/ # 对外API服务 ├── tests/ └── data/这个结构参考了Python工程的标准推荐布局也吸收了FastAPI项目和LangChain项目的好习惯。核心思路是通信层server和业务层core分离业务层内部再拆出LLM接入、Agent、工具、记忆四个模块。后面你会发现这套拆分几乎是所有AI Agent项目的标准骨架不同项目只是在这四个模块里填不同内容。2.2 技术栈选择不被全家桶绑架选型这事我吃过亏。一开始图省事装了个全功能框架所有组件都由它管。结果就是框架升级代码要改框架不支持的细节代码要绕。如果只想做demo这没问题但对一个打算持续演进的项目来说很危险。AEFS技术栈我给了一个克制的组合组件选型理由语言Python 3.11生态最全团队上手成本最低环境管理uv比pipvenv快很多锁文件可复现APT服务器FastAPI轻量、异步、带自动文档LLM统一接口OpenAI兼容协议同一个代码切不同模型几乎零成本模型本地运行Ollama数据不出内网适合工程调试特别注意第三行和第四行——很多小伙伴不理解为什么明明用OpenAI SDK又能跑本地模型这其实是过去两年最重要的一个工程趋势所有主流推理服务都在兼容OpenAI的HTTP协议。这意味着你写的代码不用绑死任何一家厂商换模型就改一行配置。这个决策帮我省了至少五次返工。配置层面我推荐使用YAML做业务配置.env做敏感配置。YAML的好处是能嵌套prompt模板、模型参数都可以结构化存放.env就存API Key这种不能进代码库的东西。示例里我写了.env.example提交到Git仓库时会先手动复制为.env使用。# 安装流程实测 curl -LsSf https://astral.sh/uv/install.sh | sh uv init ai-engineering-from-scratch cd ai-engineering-from-scratch uv add fastapi uvicorn openai pyyaml python-dotenv pydantic-settings uv add --dev pytest httpx2.3 LLM接入层所有模型长得一样的秘密有了地基接下来写第一段核心代码——LLM统一接入层。这里我刻意不直接依赖具体的第三方SDK而是封装一个ChatEngine类。好处后面你会深有体会当你有一天收到通知说某模型的API要停服了你只需要改一个类全项目其余几百处调用纹丝不动。# src/core/llm.py from openai import OpenAI from dataclasses import dataclass from typing import AsyncIterable dataclass class LLMConfig: base_url: str api_key: str model: str class ChatEngine: def __init__(self, config: LLMConfig): self.client OpenAI(base_urlconfig.base_url, api_keyconfig.api_key) self.model config.model async def complete( self, messages: list[dict], temperature: float 0.7, max_tokens: int 2048, ) - str: resp self.client.chat.completions.create( modelself.model, messagesmessages, temperaturetemperature, max_tokensmax_tokens, ) return resp.choices[0].message.content async def stream_complete(self, messages: list[dict], **kwargs) - AsyncIterable[str]: stream self.client.chat.completions.create( modelself.model, messagesmessages, streamTrue, **kwargs, ) for chunk in stream: if chunk.choices and chunk.choices[0].delta.content: yield chunk.choices[0].delta.content两三句话就能说清楚这段代码在干什么它包装了对话补全和流式对话补全两个最常用的能力把外部差异消化在构造函数里。之后你在业务代码里只需要ChatEngine实例而不管背后跑的是GPT、文心、通义还是本地Llama。这就是接口统一的工程价值。这段代码还有两个细节一是用dataclass承载配置避免参数满天飞二是流式接口用异步生成器实现将来接SSE推送非常顺手。3. 第一条业务链路从单轮问答到带工具的Agent3.1 需求拆解用户真正需要的不是问答搞工程的人最容易犯的毛病是把用户需求理解成能回答问题。我做AEFS的试验场景时请了三位潜在用户聊需求得到的真实信息是用户不会问鲁迅原名是什么这种百科全书问题他们会问帮我查一下上个月华东区销售额最高的三个客户是谁——这需要检索内部数据或者把这封邮件按领导风格改得更委婉一些——这需要理解上下文和写作规范又或者每天上午九点盯一下官网价格变了就提醒我——这需要定时任务和主动触达。看到了吗真实需求几乎都包含两个以上环节理解指令、连接外部数据、操作外部工具、产出结果。单轮问答根本覆盖不了。所以架构的第一版我就朝着Agent的方向搭建而不是做一个聊天机器人壳子。3.2 核心循环Agent的工作原理原来这么朴素Agent的核心循环网上叫ReAct模式其实白话拆开就三步思考Think→ 行动Act→ 观察Observe循环往复直到得出结论。我第一版实现得很简朴但它就是整个Agent系统的发动机。# src/core/agent.py class Agent: def __init__(self, engine, tools, memory, max_iterations6): self.engine engine self.tools {t.name: t for t in tools} self.memory memory self.max_iterations max_iterations async def run(self, user_message: str) - str: self.memory.add_user_message(user_message) for _ in range(self.max_iterations): messages self.memory.get_messages_with_system_prompt(self.tools) response await self.engine.complete(messages, temperature0.2) # 如果模型决定调用工具 if tool_call in response: action_info self._parse_action(response) result await self.tools[action_info[name]].execute(**action_info[args]) self.memory.add_tool_result(action_info[name], result) continue # 如果模型觉得可以收尾了 self.memory.add_assistant_message(response) return response return 我没能在限定步骤内完成这个任务建议把需求拆得更细一点。整个引擎就是一个for循环最多转6圈。为什么6因为实测超过6次工具调用的单轮任务极少而且迭代次数越多token浪费越严重错误累积概率越高。这里temperature0.2也是刻意的——工具调用需要确定性不需要天马行空。但这个原生实现有几个明显问题我后面逐个修补让模型直接吐原始JSON来调用工具非常容易格式出错这是初学者最容易踩的坑记忆模块没有做长度控制连续对话长了以后上下文会爆没有错误处理工具抛异常就直接崩3.3 工具注册表给Agent一双手工具设计是Agent工程里最体现功力的地方。我总结出的核心原则是工具就是函数但函数签名要写得让模型看得懂。什么意思模型不是人不会领会精神它看到的只是你给的一段字符串。所以工具的名字、描述、参数定义全部要清楚到一个没经验的实习生看了也能用对的程度。# src/core/tools.py import json from typing import Callable, Any class Tool: def __init__(self, name: str, description: str, parameters: dict, func: Callable): self.name name self.description description self.parameters parameters # JSON Schema self.func func def schema(self) - dict: return { name: self.name, description: self.description, parameters: self.parameters } async def execute(self, **kwargs) - str: try: result self.func(**kwargs) return json.dumps(result, ensure_asciiFalse, defaultstr) except Exception as e: return f工具执行失败: {str(e)}。请检查参数后重试。我用一个实际例子说明参数设计多重要。我要给Agent加一个查天气的工具第一次写的是参数: city; 描述: 城市名。结果模型调用时传的是Shanghai City工具直接找不到。改成JSON Schema后{ name: get_weather, description: 查询指定城市的实时天气。城市名需要用中文标准名称例如北京、上海、广州。, parameters: { type: object, properties: { city: { type: string, description: 城市标准中文名称如北京、上海、广州 }, date: { type: string, description: 查询日期格式YYYY-MM-DD默认为今天 } }, required: [city] } }多写了三行描述模型再也没把上海传给Sahnghai过。这就是工程化——你写工具的每一行字都是在降低模型犯错的可能性。后面我们接入更多工具时我用的是同一个套路接口交互描述 参数约束 错误兜底。3.4 记忆管理上下文窗口不是无限大的一个AI工程系统跑起来以后上下文管理是最容易出事的地方。我第一版记忆就是简单地把所有聊天记录拼起来结果测试到了第20轮对话token直接爆了模型开始胡说八道还会忘掉早期的指令。我的改进方案分两层。短期记忆用一个滑动窗口保留最近N轮对话长期记忆则把关键结论抽出来存到向量数据库里。这个滑动窗口摘要外部记忆的架构如今几乎是所有生产级AI应用的标配。# src/core/memory.py from collections import deque class SlidingWindowMemory: def __init__(self, system_prompt: str, max_rounds: int 10): self.system_prompt system_prompt self.history deque(maxlenmax_rounds * 2) # 每轮有user和assistant两条 def add_user_message(self, content: str): self.history.append({role: user, content: content}) def add_assistant_message(self, content: str): self.history.append({role: assistant, content: content}) def add_tool_result(self, tool_name: str, result: str): self.history.append({ role: tool, content: f[工具 {tool_name} 返回]: {result[:800]} }) def get_messages_with_system_prompt(self, tools: dict) - list[dict]: messages [{role: system, content: self._build_system_prompt(tools)}] messages.extend(self.history) return messages注意我给tool result做了[:800]截断——这是实操中非常关键的一步。工具返回的数据往往一大坨JSON模型只需要其中有价值的部分你全塞进去只会浪费token还干扰判断。4. Prompt工程从玄学到结构化写作4.1 为什么连系统提示词都要工程化很多刚入行的朋友觉得Prompt工程就是写一段好话术。其实不然。在我看来Prompt工程至少包含四件事角色设定、能力边界描述、输出格式约束、示例引导few-shot。一个成熟的系统提示词应该像一份工作岗位说明书而不是一段热情洋溢的动员口号。我在AEFS里为Agent设计系统提示词走的是模块化拼接路线。YAML文件里分块存代码里按需组合# configs/prompts/agent_system.yaml role: | 你是一位严谨的工程助理擅长分析问题并调用工具获取信息。 你从不编造事实不知道的信息会明确说不知道。 constraints: | 1. 你只能使用提供的工具获取信息不能假设工具之外的数据存在。 2. 如果工具返回错误请如实告知用户并建议下一步操作。 3. 回答必须简洁超过200字请在结尾附完整过程记录。 output_format: | 优先使用自然语言回答。涉及数据时使用markdown表格。不要使用表情符号。 examples: - user: 查一下北京今天的天气 assistant: 我来调用天气工具查询。北京今天晴25°C东南风3级适合户外活动。这样设计的好处是不同模块可以单独迭代。比如你发现模型经常胡编数据就改constraints发现输出的表格太丑只改output_format不会牵一发动全身。4.2 实战技巧少说不要多说要我在调试提示词时发现一个肉眼可见的规律当你写不要瞎编模型反而更容易编当你写如果未从工具获取明确数据应明确回复我目前没有足够的信息模型的诚实度立刻上升。这不是玄学是语言模型的概率分布特性——负面指令在token空间中引起的联想更复杂而正面指令更直接。所以我的提示词里几乎不出现禁止不要别这些词全部改写成你应该……的正面描述。别小看这个转变它经常能让模型的输出稳定率提高20%以上。4.3 流式输出的坑标题和正文的撕裂做Agent系统流式输出几乎是必选——用户体验上等5秒空白和等5秒一个字一个字蹦出来完全两回事。但流式也有个经典坑如果你让模型输出一段长文本比如一份分析报告流式模式下它会先把小标题吐出来再吐正文。前端如果没做缓冲处理就会出现一个700字的标题后面才接正文的怪状。我踩了这个坑后在LLM接入层做了首包延迟控制和语义段落缓冲前两个chunk只作内容预览等到积累了大于200字才正式推送前端。这个处理不复杂但对体验提升非常明显。如果你在做AI客服、AI写文档类的应用这步务必加上。5. 进阶能力让Agent学会用公司内部的知识5.1 RAG到底是什么别再听营销号扯了RAG检索增强生成这个名字看起来高大上其实就是四步走切片 → 向量化 → 存储 → 检索。你把公司几千页产品文档、服务规范、FAQ倒进一个系统里用户问一句系统先在知识库里捞最相关的5段文本再把这5段文本塞给模型告诉它根据这些材料回答。这样模型就不会自由发挥回答有出处也比重新训练一个大模型便宜得多改起来也方便——换文档立刻生效。5.2 落地实现选对向量库和Embedding模型Embedding模型这块最初我用了一款在线大模型提供的Embedding接口效果好但每天有限额调试到一半限额耗尽整个开发停摆。后来团队干脆改用本地跑一个开源的Embedding模型效果接近但再也没有额度焦虑。这个决定事后看非常明智——搞工程稳定性永远排在效果前面。向量库选型我对比过三个方案优点缺点场景Chroma轻量零配置适合试验并发差持久化弱原型验证Qdrant性能好Rust写的要独立部署生产级传统数据库pgvector和业务数据同库运维省心性能不如专用向量库已有Postgres的团队AEFS里面用的是QdrantDocker一条命令拉起提供了HTTP API和语言无关。数据量级小的项目用Chroma完全够了等量大了再迁移不迟。5.3 分块策略多少字一块最合理这是RAG里最玄学也最实操的问题。我测试过512、256、128三种常见size结论是200-300字的块配10%-20%重叠在大多数文档场景里效果最均衡。为什么一块太长检索命中后有效信息会被无关内容稀释一块太短上下文碎片化语义表达不完整。重叠则是为了对付一句话刚好被切在两块里的边界问题。还有一个常常被忽视的点切片时要保留元数据。比如这个片段来自哪个文档、哪个章节、什么日期。召回结果返回给用户时能附上参考产品手册·第二章·第3节信任度完全不一样。我见过很多项目召回效果明明还行可连内容出处都不能提供最后被用户质问你凭什么这么说——多花十分钟保留元数据能避免这整出闹剧。5.4 检索这一层也要调优检索不是拿query算个相似度就完事。我实测最有效的三步优化查询改写用户口语叫上周的审批卡在哪个环节了向量检索前先让LLM转成标准查询词工作流审批状态查询混合检索向量相似度 关键词BM25加权各取一半分能明显改善专有名词和缩写匹配重排序召回30条后用一个轻量rerank模型再排一遍只取top5给大模型就这三步我的知识问答系统准确率从58%提到了79%。很多人把RAG做得粗糙直接检索→拼接→回答效果不好还怪模型不行——真不是模型的事是链路没做扎实。6. 工程质量评测、观测、防失控6.1 没有评测你拿什么说服自己和老板AI应用和传统软件最大区别在于你不是在写一个一定对的程序你在训练一个大概率对的系统。所以必须有一个可持续运行的评测集。我给AEFS搭了一套轻量评测框架核心就一个目录加两个脚本eval/ ├── cases/ │ ├── basic_qa.yaml │ ├── tool_calls.yaml │ └── rag_eval.yaml ├── run_eval.py └── report.py用例格式长这样的# eval/cases/tool_calls.yaml - id: tc-001 input: 帮我把北京今天天气查一下 expected: [天气, 温度, 风力] type: tool_call - id: tc-002 input: 系统现在几点了 expected: [时间] type: tool_call评测脚本做的事情很简单跑一遍每个case检查输出是否包含expected中的关键词然后算通过率。每次代码改完跑一次评测分数掉了就不发布。这方法土但在团队规模不大时比那些花了三个月做了个豪华评测平台却没人维护的东西好用一百倍。6.2 观测与日志要能复盘每一次翻车线上AI系统必然出现意料之外的回答这时候没有日志等于遭难。我的经验是至少记录以下几类信息每类都得是结构化字段每轮请求的入参和出参含上下文消息数调用的工具及工具返回值摘要每次流式输出的首包延迟和总耗时模型返回的token数量、成本估算日志存储我用本地JSON Lines文件配合一个几百行的retrospective.py脚本能快速回放出某个session整个对话链路。能回溯才能改进改进不是靠拍脑袋是靠翻聊天记录找出规律。6.3 安全护栏有些话AI不能说但你要会拦聊到AI应用绕不开安全话题。我从不指望用一个提示词就让模型永不出错工程上必须有外置护栏。以AEFS为例对接一个共享的合规过滤入口输入侧和输出侧同时做检查。比如当检测到用户尝试利用越狱、恶意注入、规避规则等手段时由上游统一拦截Agent本身不需要做任何危险内容判断从而实现双向保障。再补充一个容易被忽视的操作工具层的最小权限原则。Agent能调用的工具权限必须严格限制——比如能查询数据库的Agent只能连只读账号能发邮件的Agent收件人白名单必须在配置里写死。我见过不少人图省事把生产数据库的读写权限全给了Agent一旦它被恶意引导就是灾难级事故。记住了Agent只是你的工具它不该拥有比实习生更高的权限。7. 部署上线与维护本地跑通只是开始7.1 FastAPI封装快速变成可调用的服务核心模块跑通之后下一步就是把它们包成一个服务。FastAPI做这件事很快我在AEFS里留了这个接口# src/server/app.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel from src.core.agent import Agent app FastAPI(titleAEFS Agent Service) agent ... class ChatRequest(BaseModel): message: str session_id: str default class ChatResponse(BaseModel): reply: str session_id: str app.post(/v1/chat, response_modelChatResponse) async def chat(req: ChatRequest): try: reply await agent.run(req.message, session_idreq.session_id) return ChatResponse(replyreply, session_idreq.session_id) except Exception as e: raise HTTPException(status_code500, detailstr(e))只做了两件小事入参出参用Pydantic模型约束session_id做会话隔离。但这套接口那一刻起前端、客服系统、IM机器人都可以直接对接了不用懂任何AI细节。7.2 关键配置并发、超时、重试一个不能少部署时最容易被忽视的就是超时和重试策略。LLM推理通常很慢正常3-10秒高峰期可能1分钟。如果你按普通HTTP服务那套短超时来调用户会以为系统挂了重试一多模型侧排队暴增雪崩。我的建议是读超时设90秒连接超时设10秒重试只做一次。幂等性事务比如查询类工具失败可重试非幂等比如发送邮件一定不要盲目重试否则用户可能收到两封一模一样的邮件。这些细节全是拿真实事故换来的。7.3 成本控制token就是钱别烧得不明不白最后说个所有AI项目负责人都绕不开的话题——钱。一个不算复杂的对话系统如果每次用户提问都把所有历史记录塞给模型服务10个用户后月度token账单可能就远超预期。控制成本有四个实用手段滑动窗口压缩历史过期内容移入外部记忆工具返回结果截断前面那个[:800]就是对最终输出做token上限限制防止模型话痨流式传输时一旦检测到回答的末尾信号比如包含以上就是或[end]主动断开token生成最后一条是进阶玩法但它立竿见影——一个本来会生成800字的回答AI 300字时已经把要点讲完了这时候截断成本直接砍半。系统提示词里加一行回答完整但不要超过300字实测能帮助模型自觉地简洁表达费用平均降20%-30%。8. 写在最后的经验之谈这条路上最值钱的三个习惯翻来覆去写了这么多其实核心就是三件事也是我一路踩坑踩出来的护身符。第一所有的AI行为都必须可被复现。一个现象出现一次可能是偶然出现两次还能稳定复现你才有资格谈修复。所以日志、评测用例、配置版本管理一个都不能省。第二AI系统的迭代不能靠感觉。把改动量化成分数哪怕这个评测集不够全面也比没有强。几十个用例的评测集已经能拦住80%以上的退化。第三要时刻记得这是工程不是魔法。代码要能改、要能测、要能降级回滚。那些全自动零人工一劳永逸的许诺大概率是忽悠。我想起去年第一次把这种带工具、带记忆、带知识库的Agent系统部署到生产环境时整整一周都在现场盯日志。用户每一句看起来毫不起眼的提问都可能触发一条我从未预期过的工具调用链。但正因为有了前面那些笨功夫——结构化提示词、日志回放、评测集——每次意外我都能在一小时内定位到原因。那一刻我才真正意识到这已经不再是写一段好玩儿的AI脚本而是一门有方法论、有纪律、可以长期打磨的工程学科。如果你正在从零开始别贪多求快。先把这一篇文章里最小链路跑通——一个能调工具、能连记忆、能接知识库的Agent骨架。跑通之后你的视角会立刻不一样你会开始思考评测、成本、安全、迭代。到那时候你就已经站在AI工程的门槛内侧了。