这两年,只要在技术社区里刷一会儿,就会反复撞见类似“转到人工智能是我这辈子做过的最正确的决定”的标题。有人从 Java 后端转去做大模型应用,有人从算法岗转到应用工程,也有人从运维、测试转到 AI 平台方向。这类分享读多了容易产生两个错觉:一个是以为门槛高得离谱,得先把线性代数和概率论重刷一遍;另一个是以为门槛低得离谱,会调 API 就等于转型成功。这两种判断都不太对。
真正发生变化的地方,其实不在数学层,而在工程接口层。过去做一个“能理解自然语言的业务功能”,需要算法团队、标注数据、训练周期和上线评估,链条长、成本高;现在很多需求可以直接退化成“把业务上下文组织好,交给一个模型,再对输出做约束和校验”。这意味着传统开发者的核心能力——接口编排、状态管理、异常处理、数据建模、灰度发布——并没有作废,只是换了一个落点。
我见过最多的卡点,不是学不会模型原理,而是不知道第一个能跑起来的东西该长什么样。看完一堆科普,知道 Transformer 有注意力机制,知道 RAG 是检索增强,但打开编辑器还是不知道从哪一行开始写。结果就是“概念都懂,项目为零”,简历上写不出任何可验证的东西。
这篇文章不打算再讲一遍 AI 发展史,而是把转型这件事拆成工程问题:哪些旧经验可以直接复用,哪些概念必须重新建立,三个最小可运行闭环分别怎么落地,以及在生产环境里最容易翻车的那几个地方到底怎么排查。读完你应该能自己跑通一条从调用模型到对外提供服务的完整链路,并且知道哪些坑必须提前避开。
1. 这句话真正值得拆解的地方
1.1 门槛降低发生在哪一层
把“转到人工智能”拆开看,其实是三个完全不同的方向:训练侧(预训练、微调算法、分布式训练)、应用侧(把模型能力接进业务系统)、平台侧(推理服务、调度、监控、成本治理)。绝大多数传统开发者的现实路径是应用侧,其次是平台侧。训练侧对数学、算力、数据规模的要求依然很高,并没有因为工具链进步而消失。
应用侧之所以门槛明显下降,是因为模型的输入输出被标准化成了文本或结构化数据。你不再需要自己设计特征工程,也不需要维护一个复杂的模型服务,你面对的更像一个“能力很强但不稳定、延迟较高、按量计费的下游服务”。这个描述听起来是不是很熟悉?它和调用第三方支付、调用短信网关、调用地图服务在工程形态上高度相似,只不过它的返回结果是非确定性的。
非确定性是整件事的分水岭。传统接口你写下if (status == 200)就敢继续往下走,模型接口返回 200 只代表这次调用成功了,内容对不对是另一回事。所以转型真正要补的,不是深度学习理论,而是围绕非确定性输出建立一整套约束、校验、兜底和评测机制。
1.2 什么人适合走这条路,什么人要谨慎
从公开的转型分享和招聘要求看,最适合的是有 2 年以上业务系统开发经验的人。你写过订单、支付、权限、工作流,知道什么叫幂等、什么叫超时重试、什么叫降级,这些在 AI 应用里全部用得上,而且是稀缺的。很多纯算法背景的同学能把模型效果调上去,但把服务做成能扛住线上流量、能回滚、能定位问题的形态,反而是短板。
需要谨慎的是两类人。一类是完全没有工程经验、指望靠几个提示词模板直接就业的,这类岗位正在快速内卷,因为门槛确实不高。另一类是排斥不确定性的,如果你的习惯是“必须保证每次输出完全一致才敢上线”,那在 AI 应用里会非常痛苦,因为这个领域更像做概率系统,你要做的是把错误率压到业务可接受的范围,而不是消灭错误。
另外一个容易被忽略的判断标准是:你能不能接受“效果靠评测说话”。传统开发里,一个功能对不对,跑一遍用例就知道;AI 应用里,同一个问题问十次可能有八次好两次差,你必须建立一套评测集才能判断改动是变好还是变坏。愿不愿意做这件有点枯燥的事,基本决定了他能走多远。
2. 能力迁移地图:旧经验如何映射到新岗位
很多人在转型期最大的浪费,是把已有的经验清零重新学。实际上大部分工程能力是平移的,只是命名和关注点变了。下面这张表是我认为最值得先建立起来的映射关系,建议对照自己的技术栈找一找对应项。
| 传统开发能力 | 在 AI 应用开发中的对应位置 | 迁移难度 | 说明 |
|---|---|---|---|
| 接口设计与服务编排 | 工具调用、Agent 流程编排 | 低 | 本质还是定义输入输出契约 |
| 数据库建模与查询优化 | 向量库 schema、检索参数、混合检索 | 中 | 相似度检索的调优逻辑与索引优化不同 |
| 业务逻辑抽象 | Prompt 与上下文结构设计 | 中 | 从写代码变成写约束,需要重新找手感 |
| 单元测试与 CI | 评测集、回归测试、AB 对比 | 低 | 思路一致,但断言方式从相等变成阈值 |
| 日志、监控、告警 | token 成本、首字延迟、失败率监控 | 低 | 指标换了,方法论没变 |
| 高并发与限流降级 | 模型侧配额、排队、缓存、降级到小模型 | 低 | 上游从数据库变成第三方服务 |
| 统计学与实验设计 | 评测指标、阈值调优、显著性判断 | 中高 | 需要补,但不必到能推导公式的程度 |
从这张表能看出一个结论:转型的难点集中在两个格子——“上下文结构设计”和“评测指标”。这两件事在传统开发里没有完全对应的训练,必须专门花时间。而这两件事恰恰是决定 AI 应用好不好用的关键,比会不会调 API 重要得多。
还有一个反直觉的点:代码能力不会被削弱,反而更值钱。因为模型调用只是链路中的一环,真正让应用可用的,是前后那些看起来“很传统”的部分——参数校验、缓存、重试、超时、并发控制、幂等、审计日志。一个只会写 prompt 的人做不出能上线的系统,一个能把这些工程件补齐的人,哪怕 prompt 写得一般,系统也能跑得住。
3. 核心概念与选型:RAG、微调、Agent 怎么选
在动手之前,有几个概念必须先对齐,否则很容易在错误的方案上耗掉几周。这里不追求严谨定义,重点说清楚“它解决什么问题、什么时候该用、什么时候不该用”。
大语言模型(LLM)可以理解成一个“给定上文、预测下文的函数”。它的知识截止在训练数据的时间点,它不知道你公司的内部文档,也不保证说真话。Token 是计费和信息量的基本单位,中文大致一个字到两个字一个 token,具体取决于分词器。上下文窗口指的是单次请求能塞进去的最大 token 数,超过就会报错或被截断——这是新手最容易踩的第一个坑。Embedding 是把文本映射成高维向量,语义相近的文本在向量空间里距离更近,向量检索就是靠这个特性工作。
下面这张表是我建议的选型顺序,从成本最低、见效最快开始排:
| 方案 | 解决什么问题 | 成本 | 知识更新时效 | 典型场景 |
|---|---|---|---|---|
| Prompt 与输出约束 | 角色设定、格式规范、拒答边界 | 最低 | 即时 | 所有场景的基础 |
| RAG 检索增强 | 私有知识、可溯源、减少编造 | 中 | 分钟级到小时级 | 文档问答、客服、内部助手 |
| 长上下文直塞 | 少量文档一次性分析 | 按 token 线性计费 | 即时 | 合同审阅、单次报告解读 |
| 微调 | 固定风格、固定输出格式、特定任务 | 高 | 天级 | 稳定任务、小模型降本 |
| Agent 工具调用 | 需要查实时数据、执行动作 | 中高 | 实时 | 订单查询、工单流转、运维助手 |
一个常见的误区是“效果不好就去微调”。实际上大部分“答不准”的问题,根因是知识缺失或者上下文没给对,用 RAG 就能解决,微调反而容易把模型带偏,还会引入数据准备和版本管理的新麻烦。更稳妥的判断顺序是:先确认 prompt 和上下文结构有没有问题,再考虑加检索,最后才考虑微调。
4. 环境准备与项目骨架
这里给出一套最小但完整的工程结构,能跑通后面所有示例。运行环境建议 Linux 或 macOS,Windows 用 WSL 会更顺一些。Python 建议 3.10 及以上,因为代码里用到了dict[str, Any]这种类型标注写法。具体的库版本请以你所在团队的依赖规范为准,本文重点演示通用思路,不把版本号写死。
# 创建虚拟环境并安装依赖 python -m venv .venv source .venv/bin/activate pip install -U pip pip install -r requirements.txt# 文件路径:requirements.txt openai>=1.30.0 python-dotenv>=1.0.0 fastapi>=0.110.0 uvicorn>=0.27.0 numpy>=1.24.0# 项目目录结构 ai-demo/ ├── app/ │ ├── __init__.py │ ├── llm_client.py # 统一封装模型调用 │ ├── rag.py # 切分、向量化、检索 │ ├── rag_qa.py # 检索增强问答 │ ├── agent.py # 工具调用 │ └── main.py # FastAPI 服务入口 ├── data/ │ └── faq.txt # 示例知识库 ├── eval/ │ └── run_eval.py # 评测脚本 ├── .env └── requirements.txt密钥管理是最容易被忽视的一环。绝对不能把 API Key 写进代码再提交到仓库,这是真实项目里最常见的安全事故。统一用环境变量注入,.env只用于本地开发,并且必须写进.gitignore。
# 文件路径:.env(不要提交到版本库) OPENAI_API_KEY=sk-your-key-here # 如果团队使用私有化部署或内部统一的模型网关,把 base_url 指向内部地址 OPENAI_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini EMBEDDING_MODEL=text-embedding-3-small# 文件路径:.gitignore .venv/ .env data/store.json如果你的模型服务来自内部网关,只需要改OPENAI_BASE_URL,代码逻辑完全不用动。这也是为什么第一步就要做统一封装——把服务商差异收敛到一个文件里,后面换模型才有余地。
5. 第一个可运行闭环:结构化输出与稳定调用
第一个闭环不要贪心去做问答,先做“稳定拿到结构化结果”。原因很实在:大部分 AI 应用最终都要把模型输出交给下游程序处理,如果输出格式不稳定,后面全是补丁。这个能力一旦跑通,你就已经越过了最容易劝退的那道坎。
# 文件路径:app/llm_client.py import json import logging import os import time from typing import Any from dotenv import load_dotenv from openai import OpenAI load_dotenv() logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) CHAT_MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini") def chat_json(system_prompt: str, user_prompt: str, retries: int = 3) -> dict[str, Any]: """调用模型并强制返回 json 对象,失败时按指数退避重试。""" last_error: Exception | None = None for attempt in range(1, retries + 1): try: resp = client.chat.completions.create( model=CHAT_MODEL, temperature=0, response_format={"type": "json_object"}, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], ) content = resp.choices[0].message.content or "{}" return json.loads(content) except Exception as exc: last_error = exc logger.warning("第 %s 次调用失败: %s", attempt, exc) time.sleep(2 ** attempt) raise RuntimeError(f"模型调用失败,已重试 {retries} 次") from last_error这段代码有三个关键点值得解释。第一,temperature=0并不能保证输出完全确定,它只是尽量降低随机性,所以要把它当成“减少波动”而不是“消除波动”。第二,response_format={"type": "json_object"}是一个真实的约束能力,但它通常要求提示词里明确出现 json 字样,否则可能报错,因此 system prompt 必须写清“输出 json”。第三,重试必须加退避,因为限流和网络抖动是常态,连续快速重试只会让情况更糟。
写一个测试文件验证它是否可用:
# 文件路径:app/demo_structured.py from app.llm_client import chat_json SYSTEM = """你是一个工单分类器。请输出 json 对象,格式为: {"category": "退款/物流/账号/其他", "urgency": "高/中/低", "summary": "不超过 30 字的摘要"} 不要输出任何解释性文字。""" if __name__ == "__main__": text = "我上周买的鞋子到现在还没发货,客服也联系不上,麻烦尽快处理" result = chat_json(SYSTEM, text) print(result)运行python -m app.demo_structured,预期输出类似{'category': '物流', 'urgency': '高', 'summary': '下单一周未发货且客服失联'}。如果报错提示 json 相关,先检查 system prompt 里有没有 json 字样;如果报鉴权错误,先确认.env是否被正确加载,python-dotenv默认从当前工作目录找文件,在子目录里运行会读不到。
6. 第二个闭环:RAG 检索增强问答
拿到结构化输出之后,第二个要解决的问题是“模型不知道我的资料”。最直接的办法是把资料拼进提示词,但很快就会撞上上下文窗口和成本的双重限制。RAG 的思路是把文档切成小块、转成向量存起来,提问时先检索最相关的几块,只把这几块塞进上下文。这样既控制了 token,也能给出引用来源。
# 文件路径:app/rag.py import json import os from pathlib import Path import numpy as np from dotenv import load_dotenv from openai import OpenAI load_dotenv() client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") STORE_PATH = Path("data/store.json") def split_text(text: str, chunk_size: int = 500, overlap: int = 80) -> list[str]: """按字符滑窗切分,overlap 保证跨块的句子不被切断。""" if overlap >= chunk_size: raise ValueError("overlap 必须小于 chunk_size") chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end].strip()) start = end - overlap return [c for c in chunks if c] def embed(texts: list[str]) -> np.ndarray: """批量向量化并做 L2 归一化,归一化后点积等价于余弦相似度。""" resp = client.embeddings.create(model=EMBEDDING_MODEL, input=texts) vectors = np.array([item.embedding for item in resp.data], dtype=np.float32) norms = np.linalg.norm(vectors, axis=1, keepdims=True) return vectors / np.clip(norms, 1e-8, None) def build_index(doc_path: str) -> int: text = Path(doc_path).read_text(encoding="utf-8") chunks = split_text(text) vectors = embed(chunks) STORE_PATH.parent.mkdir(parents=True, exist_ok=True) STORE_PATH.write_text( json.dumps( {"model": EMBEDDING_MODEL, "chunks": chunks, "vectors": vectors.tolist()}, ensure_ascii=False, ), encoding="utf-8", ) return len(chunks) def search(query: str, top_k: int = 3) -> list[dict]: data = json.loads(STORE_PATH.read_text(encoding="utf-8")) if data["model"] != EMBEDDING_MODEL: raise RuntimeError("索引的向量模型与当前配置不一致,请重建索引") matrix = np.array(data["vectors"], dtype=np.float32) q = embed([query])[0] scores = matrix @ q idx = np.argsort(-scores)[:top_k] return [{"text": data["chunks"][i], "score": float(scores[i])} for i in idx]这里有三个工程细节值得单独强调。第一,overlap的存在是为了避免一句话被切断在两个块之间,导致检索到一半的意思,实际项目里这个参数对召回质量影响很明显。第二,向量必须做归一化,否则点积会被向量长度带偏,相似度排序就不可靠了。第三,也是最重要的,索引里记录了生成时用的向量模型名,换模型后必须重建索引——不同模型的向量空间完全不兼容,混用会得到看似有结果但完全不相关的召回,这是最难排查的坑之一。
# 文件路径:app/rag_qa.py from app.llm_client import chat_json from app.rag import search SYSTEM = """你是企业内部文档助手。只能依据【参考资料】回答,不允许使用资料之外的知识。 如果资料中没有答案,answer 必须填写"根据现有资料无法回答"。 输出 json 对象,格式为:{"answer": "回答内容", "sources": [引用的资料编号]}""" def answer(question: str, top_k: int = 3) -> dict: hits = search(question, top_k=top_k) context = "\n\n".join(f"[{i + 1}] {h['text']}" for i, h in enumerate(hits)) user_prompt = f"【参考资料】\n{context}\n\n【用户问题】\n{question}" result = chat_json(SYSTEM, user_prompt) result["_hits"] = hits return result if __name__ == "__main__": from app.rag import build_index print("索引块数:", build_index("data/faq.txt")) print(answer("退款一般多久到账?"))运行python -m app.rag_qa,预期能看到索引块数和一段带引用的回答。判断是否成功有两个标准:一是sources里引用的编号对应的原文确实和问题相关;二是问一个资料里完全没有的问题,模型应该老实说无法回答。第二点比第一点更重要,因为它验证了拒答边界有没有建立起来。
7. 第三个闭环:Agent 工具调用与循环控制
第三个能力是让模型去“做事”,而不是只“说话”。实现方式叫工具调用(function calling):你把可用的函数和参数结构描述给模型,模型决定什么时候调用哪个函数、传什么参数,你的程序执行完再把结果喂回去。注意执行动作的是你的代码,模型只负责决策,这个边界必须分清,否则会写出很大的安全隐患。
# 文件路径:app/agent.py import json from app.llm_client import client, CHAT_MODEL TOOLS = [ { "type": "function", "function": { "name": "query_order_status", "description": "根据订单号查询订单当前状态,只有用户提供订单号时才调用", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,例如 A10023", } }, "required": ["order_id"], }, }, } ] def query_order_status(order_id: str) -> str: # 示例数据,实际项目里替换为受权限控制的服务调用 fake_db = {"A10023": "已发货,预计明天送达", "B20011": "待付款"} return fake_db.get(order_id, "未找到该订单") TOOL_MAP = {"query_order_status": query_order_status} def run_agent(user_input: str, max_steps: int = 5) -> str: messages = [ {"role": "system", "content": "你是订单助手。需要查询时调用工具,不要凭空猜测订单状态。"}, {"role": "user", "content": user_input}, ] for step in range(max_steps): resp = client.chat.completions.create( model=CHAT_MODEL, messages=messages, tools=TOOLS, temperature=0, ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content or "" messages.append( { "role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls], } ) for tc in msg.tool_calls: name = tc.function.name args = json.loads(tc.function.arguments or "{}") func = TOOL_MAP.get(name) result = func(**args) if func else f"未知工具: {name}" messages.append( { "role": "tool", "tool_call_id": tc.id, "content": str(result), } ) return "超过最大推理步数,已终止" if __name__ == "__main__": print(run_agent("帮我查一下订单 A10023 现在什么状态"))这段代码里最关键的其实是max_steps。模型的推理循环没有天然的终止保证,可能反复调用同一个工具、反复生成调用参数,没有步数上限就会不断烧 token 甚至卡死。生产环境里还要加上单次请求的超时和总 token 预算,这两个是必须的护栏。
另外,工具描述里的“只有用户提供订单号时才调用”这类约束很重要。模型对函数描述的理解程度直接决定调用准确率,描述写得越具体、越像给人看的说明书,效果越好。参数校验也不能省:模型可能编出一个不存在的订单号,甚至传入超长字符串,函数内部必须做类型和长度校验。
8. 服务化、效果验证与常见问题排查
前面三个闭环都是在本地跑的脚本,最后一步是把它们变成能被其他系统调用的服务。这里用 FastAPI 做一个带流式输出的接口,流式不是为了炫技,而是因为大模型的首字延迟通常在几百毫秒到几秒之间,非流式接口会让用户觉得卡住了。
# 文件路径:app/main.py import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel from app.llm_client import client, CHAT_MODEL from app.rag_qa import answer app = FastAPI(title="AI Demo") class AskRequest(BaseModel): question: str @app.get("/health") def health() -> dict: return {"status": "ok"} @app.post("/rag") def rag_endpoint(req: AskRequest) -> dict: result = answer(req.question) return {"answer": result.get("answer"), "sources": result.get("sources", [])} @app.post("/chat") def chat(req: AskRequest) -> StreamingResponse: def event_stream(): stream = client.chat.completions.create( model=CHAT_MODEL, messages=[{"role": "user", "content": req.question}], temperature=0.3, stream=True, ) for chunk in stream: # 部分兼容实现可能返回空 choices,必须判空 if not chunk.choices: continue delta = chunk.choices[0].delta.content if delta: yield f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream")启动方式和服务验证:
uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload # 健康检查 curl http://127.0.0.1:8000/health # 结构化问答 curl -X POST http://127.0.0.1:8000/rag \ -H "Content-Type: application/json" \ -d '{"question":"退款一般多久到账?"}' # 流式输出,-N 关闭 curl 缓冲 curl -N -X POST http://127.0.0.1:8000/chat \ -H "Content-Type: application/json" \ -d '{"question":"用一句话解释什么是向量检索"}'判断成功的方式要分层看:HTTP 层返回 200 只代表没崩,业务层要看/rag返回的sources是否指向相关原文,体验层要看/chat是不是逐段吐字而不是一次性返回。如果流式接口一次性返回全部内容,多半是中间有反向代理开了响应缓冲,或者漏了-N参数。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 启动报密钥为空 | .env未被加载或工作目录不对 | 打印环境变量是否读到 | 在项目根目录启动,或显式指定 env 文件路径 |
| 报上下文长度超限 | 检索块太多或单块太长 | 统计拼进 prompt 的字符数 | 减少 top_k、缩短 chunk_size、做历史裁剪 |
| 模型输出不是合法 json | 提示词未声明 json,或温度过高 | 打印原始返回内容 | system 中明确要求输出 json,temperature 设为 0 |
| 检索结果完全不相关 | 换了向量模型但没重建索引 | 对比索引中记录的模型名 | 重建索引,索引与模型版本绑定 |
| 召回结果总差一点 | 切块过大导致语义被稀释 | 抽样看命中的 chunk 内容 | 调小 chunk_size,增加 overlap |
| 回答里出现资料外内容 | 缺少拒答约束 | 问一个资料里没有的问题测试 | 提示词中强制“无答案则拒答”,并保留引用编号 |
| 频繁报限流错误 | 并发过高或重试太激进 | 看错误码和调用频率 | 加并发上限,重试使用指数退避加随机抖动 |
| 流式输出卡住不动 | 代理缓冲或未刷新 | 直连服务测试 | 关闭代理缓冲,逐块 flush |
| 工具被反复调用不停 | 缺少步数上限 | 打印每轮消息 | 设置 max_steps 和总 token 预算 |
| 成本突然翻倍 | 上下文变长或缓存失效 | 统计每次请求的 token 用量 | 加结果缓存,压缩上下文,按场景选小模型 |
9. 工程最佳实践与三个月转型路线
跑通 demo 和生产可用之间,隔着一段不短的距离。这一段距离里最值钱的几件事,恰恰是传统工程经验的用武之地。
第一件事是把 prompt 当代码管理。提示词散落在各个文件里、改了就上线、出问题不知道改回了什么版本,这是非常普遍的状态。更稳妥的做法是把 prompt 抽成独立文件或配置项,带版本号和变更记录,每次改动都跑一遍评测集。评测集不需要很大,几十条覆盖核心场景和边界情况就够,关键是每次改动都跑,形成回归。
# 文件路径:eval/run_eval.py from app.rag import search CASES = [ {"question": "退款多久到账", "expect": "工作日"}, {"question": "怎么修改收货地址", "expect": "地址"}, {"question": "发票怎么开", "expect": "发票"}, ] def main() -> None: hit = 0 for case in CASES: hits = search(case["question"], top_k=3) joined = "".join(h["text"] for h in hits) ok = case["expect"] in joined hit += int(ok) print(("PASS" if ok else "FAIL"), case["question"], "->", [round(h["score"], 3) for h in hits]) print(f"召回命中率: {hit}/{len(CASES)} = {hit / len(CASES):.2%}") if __name__ == "__main__": main()这个脚本看起来简陋,但它解决了一个真问题:调完切块参数后,你怎么知道是变好还是变坏。凭感觉调参是 AI 应用里最容易失控的地方,有了这个基线,每次改动都有数可依。
第二件事是把可观测性补齐。每次请求至少记录:请求 ID、模型名、输入 token、输出 token、耗时、是否命中缓存、是否触发重试、最终状态。这不是为了好看,而是因为成本失控和延迟抖动在 AI 应用里出现得比传统服务更频繁,没有这些数据你根本不知道钱花在哪。
第三件事是把安全边界写死。密钥只走环境变量或密钥管理服务;工具调用的每个函数内部都要做权限校验和参数校验,不能因为“模型说可以”就放行;涉及写操作、删除、支付的工具,必须有人工确认或二次校验,绝不能交给模型自主决定。这几点在演示阶段看不出问题,上线后是会出事的。
第四件事是控制成本。简单任务用便宜模型,复杂任务才升级;相同问题加结果缓存;上下文只放必要信息,不要图省事把整篇文档塞进去。这些做法听起来朴素,但对账单的影响往往是数量级的。
关于转型路线,我给一个比较务实的三个月节奏,供参考调整:
- 第 1 个月:补齐概念和手感。跑通本文的三个闭环,理解 token、上下文窗口、temperature、embedding 这些参数的实际影响。目标是能独立做出一个能用的内部小工具。
- 第 2 个月:补工程。加评测集、加日志、加缓存、加限流,把 demo 改造成能被别人依赖的服务。同时开始看一两个主流框架的源码或文档,理解它们如何组织检索和流程编排。
- 第 3 个月:做出一个有业务背景的完整项目。选一个你熟悉的业务领域,把知识库、检索、工具调用、前端展示串起来,并把评测数据和成本数据整理出来。面试时能讲清楚“为什么这么选、踩过什么坑、怎么验证效果”,比罗列框架名有说服力得多。
需要提醒的是,这个领域的工具链变化确实快,今天流行的框架明年可能就换了。所以真正值得投入的是那些不随框架变化的东西:怎么设计上下文结构、怎么建立评测、怎么控制成本和延迟、怎么划清权限边界。这些能力换个框架依然成立。
回到最开始那句话。“转到人工智能是不是最正确的决定”这个问题,其实没有标准答案,因为它取决于你能不能接受一个输出不确定、需要持续调优、评价标准靠数据说话的工作方式。但有一点可以确定:如果你已经具备扎实的工程能力,那么进入这个领域的最短路径不是重新读一遍数学,而是把手上这套工程方法迁移过去,先跑通一条最小链路,再一点点把不确定性管住。第一步永远是让代码真正跑起来,而不是再多看一篇科普。