资讯动态

AI工程从零实践:分层定位、上下文工程与评测闭环

发布时间:2026/10/4 18:07:52 来源:尧图企业网站定制
做了几年AI应用开发我一直觉得AI工程最值钱的能力不是手速而是判断力。网上“从零入门大模型应用”的文章多如牛毛但绝大多数只教你如何更快地调用模型接口不教你当系统输出不符合预期时问题到底出在模型、Prompt、数据、上下文还是架构层。最近我在整理自己的实践笔记核心思路正是“ai-engineering-from-scratch”先用手写最小实现把整条链路摸一遍再去谈框架和优化。这篇文章就是这套笔记的公开版适合已经能调通API、但还没做出“稳定可用”AI应用的人读。它不教你三分钟部署Demo教你怎么在真实项目里建立定位问题、评估质量、控制风险的完整闭环。先抛一个反直觉的结论越是看起来复杂的AI工程越应该从零开始拆着做一遍。这个“from scratch”不是要你从反向传播开始训练模型而是让你亲手把应用链路里的每个环节看一遍。只有当你清楚每个选择的代价和收益碰到线上问题才知道从哪里下手。1. 为什么“from scratch”不是让你从零造轮子而是从零建立判断力1.1 只会调Prompt等于闭着眼修车没人愿意承认但这是大多数AI应用开发者的真实状态。把一个客服对话机器人接上线前几条测试都通过了正式环境一跑就出问题。此时你观察到的症状是“模型回答不对”但原因可能来自五个不同的层次用户问题本身含糊模型不知道该答什么文档切分不合理该进上下文的资料根本没进去检索结果排序有问题前排全是无关片段Prompt里的规则互相矛盾模型不知道听谁的采样参数不匹配任务事实问答用了过高的temperature。如果只看症状就继续改Prompt就像闭着眼修车你听见发动机有异响却不知道是火花塞、油路还是传感器的问题只能换这个试那个运气好偶然修好运气差越修越糟。所以“from scratch”的本质是建立一套分层定位问题的能力。遇到问题的第一反应不该是“再改一版Prompt”而是先判断这个问题来自数据层、检索层、生成层还是调度层。1.2 分层定位AI应用出问题先判断是哪一层我自己习惯把AI应用链路分成六层需求层、数据层、上下文层、生成层、调度层、产品层。排障时按顺序过一遍。需求层用户到底想问什么问题本身是否清楚。数据层文档清洗是否干净是否存在大量互相矛盾的旧资料。上下文层模型到底看到了什么检索结果是否命中关键信息。生成层模型有没有遵守输出格式有没有事实编造。调度层Agent每次循环是否正确调用了工具结果有没有传回。产品层交互设计是不是让模型陷入歧义比如同一个输入框既要聊天又要查资料。这套层次感不是看书能得来的必须在亲手搭建最小实现的阶段踩过坑才会建立。这也是我不建议第一版就上重型框架的原因——框架把很多层抽象成了黑盒你调用起来很爽出问题时却不知道黑盒里发生了什么。2. 先吃透模型的“交互语言”Token、上下文窗口与消息协议2.1 Token计费与延迟的最小颗粒所有AI工程的成本模型都建立在Token之上。文本在被模型处理前会切分成Token中文通常是单字或双字一组英文一个单词可能是多个Token。我见过不少开发者聊起来头头是道追问一句“你线上那个日志查询功能平均一次请求消耗多少Token”答不上来。这个基础不牢后面的预算分配和性能优化都是空谈。工程上有两个务实建议。第一在代码里封装一个稳定的Token计数器不要用len(text)粗略估算真实项目里建议基于模型对应的tokenizer来做误差可控。第二对常见输入做一次统计比如“一个问题检索结果历史消息的组合平均多大”你会发现模型的输入远比你想象的长成本也就远比你想象的高。很多项目上线后账单超标原因就是没人做过这种基础统计。2.2 上下文窗口一切设计的硬边界上下文窗口是模型一次能“看到”的Token总量它同时决定输入和输出的上限。很多人只把它当成一个容量参数实际上它是你所有设计的边界文档太长就要切分对话太久就要压缩检索结果太多就要重排。工程上最重要的是给输出预留空间。比如一个128K窗口的模型你让它在一次调用里消化一整本书并输出长文如果输入塞了125K输出只剩3K被截断后你得到的就是半截答案。我自己习惯在封装层里硬性保留10%到20%的输出预算宁可牺牲一点输入长度也要保证结果完整。另一个容易被忽略的点是连续多轮对话里历史消息会悄悄占满窗口必须在每一轮调用前做一次裁剪。def count_tokens(text: str) - int: # 演示用近似值真实项目请使用模型对应的 tokenizer return max(1, len(text) // 2) def trim_history(messages, max_tokens32000, reserve_output4096): # 从最新消息开始往前保留同时保证 system 永远在开头 budget max_tokens - reserve_output kept [] for msg in reversed(messages): cost count_tokens(msg.get(content, )) if budget - cost 0: break kept.append(msg) budget - cost kept.reverse() first messages[0] if messages else None if first and first.get(role) system: if not kept or kept[0].get(role) ! system: kept.insert(0, first) return kept这段代码的取舍很典型宁可超一点预算也要保留system消息因为行为设定一旦丢失整段对话的规则都会崩掉。真实环境里你还会遇到“最早对话里的信息对当前问题很重要”的情况所以还得加摘要机制而不是简单丢弃。这个问题我放到第4节展开。2.3 消息协议与采样参数容易被忽略的工程语义多轮对话要维护结构化的消息列表也就是system、user、assistant交替出现而不是简单拼接字符串。原因在于模型对消息边界敏感。把用户内容和助手回复混成一个文本块模型在深层意图判断上会变得模糊。你要让模型清晰地知道上一句是用户说的还是自己说的这是后续推理的基础。采样参数方面我强烈建议按任务分类设置。事实问答、信息抽取、工具调用统一用低温0到0.2就图一个稳定文案写作、头脑风暴才用中高温0.7到1.0。很多人习惯所有场景都用默认值这是不对的。尤其在Agent场景里高温会让模型“灵机一动”把工具参数改掉。这是我真实踩过的坑同一道算术题一次算12一次算13区别只是temperature设在了0.8。事实型任务里创意腾挪的空间不需要那么大稳定是第一位的。3. Prompt工程的本质把人与机器之间的“接口”设计清楚3.1 一个好Prompt的四个组成部分很多人把Prompt当作文来写讲究措辞华丽、气势磅礴这是方向性错误。Prompt是接口契约。一份完整可用的Prompt通常包含四段任务指令、参考材料、输出约束、示例。任务指令说明做什么参考材料给本轮回答的依据输出约束限定格式示例告诉模型你想要的标准形态。这里还要注意放置顺序指令和参考材料尽量靠前模型对上下文中间部分存在注意力衰减信息放在开头和结尾更容易被利用。我常用的一个骨架长这样SYSTEM_PROMPT 你是面向企业内部的文档问答助手。 回答边界 - 只依据【参考文档】回答不进行无依据的推断 - 如果参考文档没有答案明确说“资料中没有覆盖这个问题”不要编造 - 不要回答与文档无关的问题。 输出要求必须严格遵守 - 判断问题是否在参考文档范围内 - 在范围内时输出JSON {answer: 回答正文, source_file: 依据文件名, confidence: high/medium/low} - 不在范围内时输出JSON {answer: 抱歉资料中没有覆盖这个问题。, source_file: null, confidence: low} 把这个骨架里的三块拼好一个Prompt就能进评测了。如果你做完之后仍然觉得模型“不听话”多数情况不是措辞问题而是参考材料缺失或输出约束不够硬。3.2 为什么Few-shot比形容词管用你可以在Prompt里写“请一定要简洁”也可以给一个“简洁到什么程度”的例子后者通常有效得多。原因是模型本质上在做模式匹配与续写给它两个完整示例它在输出结构上会主动向示例靠拢。示例不仅是内容示范也是在指定输出协议。一个常见误区是只给正例不给反例。对边界问题的处理一个反例能胜过十句抽象规则。当模型需要学会“不确定就澄清不要瞎猜”时我通常会给两个正例和一个负例负例明确标为“不可接受的输出”。模型的模仿能力很强你把不可接受的行为直接摆出来它反而更容易记住边界。3.3 工程侧的三条避坑经验第一动态数据不要硬塞进system prompt。系统提示词应该放不太变化的规则用户名单、实时库存、检索到的文档要放在独立的上下文块里。否则你会把system prompt变成一个大杂烩换一个用户就要整段拼接无法复用也难排查。第二要求输出JSON就让它“只输出JSON”别让它加解释文字。但解析层仍然要容错因为模型偶尔会加代码块标记或者夹带一句“好的”。此时可以在解析层做一个提取函数把最外层JSON对象从杂音中剥离。第三基于同一个底模不要频繁改system prompt的表达风格。改一次可能有一点点效果但改变本身就是噪声。如果当前版本已经过了评测、没有明显badcase就别为了“看起来更严谨”去重写。真正驱动效果提升的往往是上下文质量和数据质量不是措辞。4. 上下文工程模型回答的天花板很大程度取决于你喂进去的上下文4.1 RAG的核心链路与切分策略上下文工程里提得最多的是RAG但RAG不是“把文档塞进向量库就完事”。完整链路是清洗、切分、向量化、入库、召回、重排、组装、生成。里面最容易被忽视的是切分和召回。切分没有固定参数。我的经验是尽量按文档语义边界切一个chunk能自包含地回答一个问题如果文档本身没有明显结构再退回固定长度300到500字作为起点重叠50到100字。固定长度切分的常见事故是把一句话的上半段和下半段分到两个chunk里召回时谁都不完整生成的答案自然前言不搭后语。这里还要补一句关于检索的常识向量检索适合查“语义相似”不适合查“关键词精确”两者要结合。有些问题本身就是标准术语比如模型版本号、产品型号关键词检索反而更稳。所以成熟的RAG系统往往是“混合检索”不是单纯向量语义检索。第一版就上混合检索可能有点复杂但至少要在设计时意识到靠单一向量召回是有盲区的。4.2 记忆策略短期、长期与外部记忆上下文工程不只包括文档检索也包括对话记忆。对话系统里我通常分三层处理。短期记忆当前会话上下文用滑动窗口裁剪保留最近几轮关键信息。长期记忆对旧会话做摘要比如每20轮生成一段摘要存起来新会话需要时再把摘要并入上下文。外部记忆明确的事实型知识放数据库或向量库每次需要时实时查询不长期放在上下文里。三者的边界经常被忽略导致模型把“上次聊过的旧信息”和“当前检索文档”混为一谈。解决思路是在拼接上下文字段时加明显的区块分隔标签并在系统提示词里规定引用优先级。否则模型面对一堆混杂信息很容易把旧记忆当成新事实。4.3 组装上下文的模板我常用的组装模板长这样以下是本次回答需要参考的文档片段片段按相关度从高到低排列 context ... /context 以下是与用户相关的近期对话摘要 memory ... /memory 用户当前问题 user_query ... /user_query这样做的工程价值是把不同来源的信息打上标记。模型在生成时更容易遵循“只依据上下文片段”的约束。如果你想走得更远还可以在多个来源给出同一事实但表述不同时让模型优先采用日期更新的片段前提是检索结果带元数据。这些细节加在一起就是上下文工程的核心你不是在“填满窗口”而是在“管理证据”。5. 从零写一个最小Agent把“自主闭环”的骨架搭出来5.1 Agent的本质是一个循环很多人一说Agent就想到复杂编排、多角色协作、认知架构。其实最小Agent只是一个循环模型根据当前状态决定调用哪个工具工具执行结果返回模型看到结果后决定继续调用还是结束。把这个循环写出来几十行代码的事。在这个循环里工具描述与消息反馈是质量关键。工具如果不写清楚参数含义模型就不知道传什么。工具返回结果如果不规整模型也无法判断执行是否成功。所以工具定义不只是一个函数更像是一份给模型看的说明书。5.2 最小可用的Agent代码下面这个代码是完整可运行骨架只是把模型调用抽象成了llm(messages)。你把自己的模型接口填进去就可以观察Agent循环的每一步。import ast import operator import json TOOLS {} def register_tool(name, description, parameters): def decorator(fn): TOOLS[name] {fn: fn, description: description, parameters: parameters} return fn return decorator ALLOWED_OPS { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, } def safe_eval(node): if isinstance(node, ast.Expression): return safe_eval(node.body) if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return node.value if isinstance(node, ast.BinOp) and type(node.op) in ALLOWED_OPS: return ALLOWED_OPS[type(node.op)](safe_eval(node.left), safe_eval(node.right)) raise ValueError(不支持的表达式) register_tool( calculator, 计算数学表达式参数expr为合法的数学表达式字符串比如 (35)*2。, { type: object, properties: {expr: {type: string}}, required: [expr], }, ) def calculator(expr: str): try: return str(safe_eval(ast.parse(expr, modeeval))) except Exception as e: return f计算失败: {e} SYSTEM_PROMPT 你是自动化助理。每轮必须输出一个JSON action {name: 工具名, args: {...}} 如果你认为任务已完成输出 {name: __end__, args: {result: 最终答案}} def parse_action(text: str): try: return json.loads(text) except Exception: return None def llm(messages): # 替换成你的模型调用传入消息列表返回文本 raise NotImplementedError def run_agent(user_input, max_steps8): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] for step in range(max_steps): text llm(messages) action parse_action(text) if action is None: return f第 {step 1} 步输出无法解析: {text} if action[name] __end__: return action[args].get(result, ) tool TOOLS.get(action[name]) if tool is None: return f工具不存在: {action[name]} try: result tool[fn](**action[args]) except Exception as e: result f工具执行异常: {e} messages.append({role: assistant, content: text}) messages.append({role: user, content: f工具[{action[name]}]返回{result}}) return 超过最大步数已主动终止。为什么我用“输出JSON action”而不是直接上原生的function calling因为JSON模式能让你看到Agent每一步的“想法”调试成本极低。生产环境当然可以用function calling获得更强的参数结构化能力但从零实现时先用JSON把原理看明白远比直接调API重要。5.3 边界控制比工具本身更重要Agent最可怕的行为不是“做错”而是“停不下来”。模型在循环里可能会反复调用工具、反复给出同一个错误动作。最小实现里这个约束表现在三个地方最大循环步数、单次工具调用超时、累计Token预算。我建议从第一版就加齐这三样东西。最大循环步数默认不超过8单次工具调用要有超时默认10秒累计Token预算超出后强制停止返回已得到的结果。除此之外任何涉及真钱、外发消息、删除操作的Agent都要加人工确认节点。模型生成参数时可能把“发送给张三”变成“发送给所有人”这类错误在传统软件里几乎不可能出现在Agent里就是一次函数调用的事。6. 评测先行给AI工程装上“进度条”6.1 评测集怎么建AI应用没有评测集就上线等于传统软件没有测试用例。但AI的评测集不等于“标准答案列表”而是“问题判定标准”。第一步从真实使用场景收集问题。至少50条起步100条比较理想。来源不是你自己臆想而是客服记录、用户反馈、历史badcase。第二步给每个问题写判定标准。事实类问题写清应包含的实体开放类问题写清应该出现的观点维度必须判定“是否编造”的标注参考来源。第三步把评测集纳入每次改动流程。每改一版Prompt、每换一次模型都要跑一遍全量对比通过率。这一步能避免“今天感觉好了明天又崩了”的循环。6.2 关键指标怎么选综合效果不能只看一个总通过率。我在项目里至少会统计四类指标说明适合场景任务完成率评测集里判定为通过的占比所有任务格式正确率JSON解析成功率、必填字段完整率输出结构化数据的任务检索命中率召回结果中包含标准答案来源的比例RAG系统工具调用成功率工具返回异常的比例Agent场景不要只用一个总通过率。我见过项目号称“95分”上线后前端解析接口疯狂报错原因就是没人单独统计格式正确率。答案质量再高字段结构不对产品侧一样跑不通。6.3 用LLM当裁判的三条实践用另一个LLM给系统输出打分是可行的但有三条实践要记住。第一裁判模型只看用户问题与被测系统的最终回答不要看被测系统的Prompt否则会产生合并偏误——裁判会被Prompt里的风格带偏。第二要求裁判给出判断理由不只要分数。第三对裁判结果做10%到20%的人工抽样复核防止裁判被连续同一答案“洗脑”。裁判温度设置为0先给它几个边界用例校准再进入正式评测。6.4 别把迭代变成乱开枪多数团队死在迭代节奏上。上午觉得“加一句强调严谨”下午觉得“把temperature降一降”晚上又换了一版示例。每次改动都没有评测结论做依据最后整个Prompt变成没人能理解的缝合怪。我自己的节奏是一次只改一个变量。改完跑全量评测记录通过率变化。提升就保留没提升就回滚。如果连续两版改动都没有正向提升我会停止改Prompt转头检查上下文质量和数据问题。这时候问题多半不在Prompt。7. 从最小系统到渐进架构我建议的实践路径7.1 四个阶段的演进第一阶段约两周一个脚本一个Prompt一个结果解析函数。目标是跑通端到端体验链路每一环不接任何框架。第二阶段加入评测集每一条新增能力都先写评测样本给系统装“进度条”。第三阶段加入外部知识与记忆。引入RAG时先单独验证“检索结果是否命中”再验证组合后的问答质量。第四阶段服务化与运维加缓存、限流、日志、Token成本监控线上遇到badcase就回流到评测集。这四个阶段不是一刀切的时间表而是能力递进关系。没有评测做基底就进入架构阶段只会放大不确定性。7.2 什么时候才需要框架框架不是不能用而是要等到“自己写的最小实现已经让你感到重复劳动”的时候再上。如果每个项目都要重写一遍Prompt组装、召回拼接、上下文裁剪团队好几个人都在做同一件事这时候抽象公共层或者引入框架是划算的。反过来说如果最小实现都还没跑通一上来就上重型框架等于让刚会踩油门的人上高速。风景很好但一旦爆胎连备胎在哪都不知道。我给每个阶段配了一张常看的表阶段核心目标最容易踩的坑脚本原型判断可行性摸清链路一上来就接框架评测基线让质量可测量不写评测就反复改Prompt知识增强提升回答的事实密度召回命中率低却还在改生成层服务化稳定控制成本与风险只看效果不看Token成本7.3 一个具体项目的复盘我最近做的一个内部工具本质是“读一堆产品文档回答新员工问题”。第一版直接用一个Prompt塞了几篇文章效果很飘。后来老老实实把文档切块、建索引、写评测集三个下午搞定。评测通过率从62%升到85%以上靠的并不是更花哨的Prompt而是把检索命中率从55%提到78%。这个动作让模型每次都能看到正确依据回答自然就稳了。经验就一句话先让模型看到该看的再谈让它说好话。这套“from scratch”的思路也改变了我的工作习惯。现在每接手一个新AI项目我都会先写一个最小实现哪怕几个小时就完成也一定要亲手走一遍链路。它看起来不如“三分钟接入框架”炫酷但每次都能帮我找出藏在两层之间的真实问题。如果你也正处在“能跑通Demo、但线上不稳”的阶段建议你也腾出一周时间把这条路从零走一遍。踩过的坑会变成判断力而判断力才是AI工程里最稳定的资产。

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

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

免费获取报价 →
↑