<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh_CN">
  <title>伊橙</title>
  <subtitle>伊橙的博客</subtitle>
  <link href="https://cnyicheng.top/" rel="alternate" type="text/html"/>
  <link href="https://cnyicheng.top/atom.xml" rel="self" type="application/atom+xml"/>
  <id>https://cnyicheng.top/</id>
  <updated>2026-10-23T05:10:00.000Z</updated>
  <entry>
    <title>DeepSeek 拿到一千亿</title>
    <link href="https://cnyicheng.top/posts/deepseek-100b-round/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/deepseek-100b-round/</id>
    <published>2026-10-23T05:10:00.000Z</published>
    <updated>2026-10-23T05:10:00.000Z</updated>
    <summary>彭博社十月二十二日曝出，DeepSeek 完成约 1000 亿元人民币融资，宁德时代和腾讯在列。融资前两天，它刚开源了推理框架 DeepInfer；一周前发布的 V4.1 Flash 把单项任务成本压低了六成。把钱和成本放在一起看，这笔账才成立。</summary>
    <content type="html"><![CDATA[<p>十月二十二日下午，彭博社的消息传出来：DeepSeek 在最新一轮融资里筹到约 1000 亿元人民币，合 150 亿美元。宁德时代和腾讯是本轮规模最大的投资方之一。</p>
<p>知情人士说，融资目标中途至少上调过两次，最早是 500 亿，后来 800 亿，最后落到 1000 亿。</p>
<p>目标上调这个细节比总额更有意思。它说明这不是一份预先定好的计划，而是越谈越贵。</p>
<h2>1. 这轮的数字</h2>
<table>
<thead>
<tr>
<th>项目</th>
<th>情况</th>
</tr>
</thead>
<tbody>
<tr>
<td>融资规模</td>
<td>约 1000 亿元人民币，约 150 亿美元</td>
</tr>
<tr>
<td>目标变化</td>
<td>500 亿 → 800 亿 → 1000 亿</td>
</tr>
<tr>
<td>主要投资方</td>
<td>宁德时代、腾讯</td>
</tr>
<tr>
<td>IPO 安排</td>
<td>已聘中信证券，筹备上交所科创板（路透社 9 月 9 日）</td>
</tr>
<tr>
<td>同行动作</td>
<td>月之暗面推进 2027 年一季度港股 IPO</td>
</tr>
</tbody>
</table>
<p>150 亿美元放在全球看不算最大。OpenAI 这一轮在谈的最高 300 亿美元，投前估值约 1.25 万亿美元。但放在中国 AI 行业里，这是到目前为止最大的一笔，而且是第一次有电池厂和社交平台同时出现在一份大模型融资名单里。</p>
<h2>2. 融资前两天，它开源了推理框架</h2>
<p>十月十七日，DeepSeek 发布并开源 V4.1-Flash-1017。按智东西的报道，这个模型在 DeepSWE v1.1 上的得分超过了 GPT-6 Astra、GPT-6.1 Sol 和 Claude Opus 5，单项任务的平均费用比上一版下降约六成。</p>
<p>十月二十日，它开源了内部使用的大规模云服务推理框架 DeepInfer，以及生产级的 token 编解码组件 deepseek-recipe，还附了完整的技术报告。</p>
<p>从模型到框架隔了三天，从框架到融资消息隔了两天。</p>
<p>榜单是可以买的，框架不行。把推理栈开源，等于把"便宜"从一句结论变成一套方法，别人可以照着复现你的成本曲线。</p>
<p>对写代码的人来说，这个区别很实际。API 降价你只能被动接受，开源框架你可以自己跑一遍，算清楚一次请求到底花多少显存、多少时间。</p>
<h2>3. 成本降了，钱为什么还要更多</h2>
<p>单位成本往下走，总支出未必跟着降。</p>
<p>这条逻辑我在《AI 成了加息的理由》里写过宏观版本，放到一家公司身上同样成立。单次推理便宜六成，用的人会变多，单人的调用次数会变多，总消耗量涨得比单价降得快，最后还是缺卡。</p>
<p>Anthropic 的数据可以当参照。它第三季度营收 168 亿美元，环比增长 45%。增速比第二季度的环比翻倍放缓了，绝对额还在往上走。</p>
<p>DeepSeek 拿钱之后的动作也指向同一个方向。彭博社说它在加大数据中心的投入，同时开发更多面向国内和海外市场的开源模型。</p>
<table>
<thead>
<tr>
<th>变量</th>
<th>方向</th>
</tr>
</thead>
<tbody>
<tr>
<td>单次推理成本</td>
<td>下降</td>
</tr>
<tr>
<td>调用总量</td>
<td>上升，更快</td>
</tr>
<tr>
<td>算力需求</td>
<td>上升</td>
</tr>
<tr>
<td>融资需求</td>
<td>上升</td>
</tr>
</tbody>
</table>
<p>这四行的方向一致，所以融资规模和推理效率不是对立的两件事。</p>
<h2>4. 开源这门生意的现金流</h2>
<p>开源和闭源，收入的节奏不一样。</p>
<p>闭源模型卖 API，收入随调用量线性增长，毛利高，现金回流快。开源模型把权重放出去，收入来自企业部署、云厂商分成，以及自建服务的溢出需求，确认得慢，前期要靠融资撑着。</p>
<p>所以 1000 亿里有很大一部分买的是时间。在 IPO 之前，把算力、模型迭代和海外客户这三件事同时撑住。</p>
<p>科创板 IPO 的意义也在这里。私募的钱有期限，公开市场的钱没有。DeepSeek 今年九月就聘了中信证券，融资和上市是同一件事的两个阶段。</p>
<h2>5. 三个还没有答案的问题</h2>
<p>估值还没有公开数字。这轮没有披露投后估值，1000 亿换来多少股份不清楚，"贵不贵"现在算不出来，要等招股书或者下一轮。</p>
<p>算力是另一回事。钱到账和机房通电之间隔着电、卡和工期，这部分不受融资节奏控制，钱到位了也不一定解决得了。</p>
<p>收入结构更模糊。开源模型的商业化路径有好几条，API、企业私有化部署、云厂商分成，哪一条能撑起 IPO 的定价，目前没有公开数据。</p>
<h2>6. 我的看法</h2>
<p>这笔钱要买的是更低的单位成本，还有时间。</p>
<p>过去两年国内大模型比的是榜单，现在比的是成本曲线。谁的推理更便宜，谁就能把调用量吃下来，然后拿着调用量去谈算力和融资。DeepSeek 这一轮的顺序很清楚：先把成本打下去，再把推理框架开源，然后拿钱建机房。</p>
<p>对写代码的人来说这是好事，推理越便宜，能做的事越多。至于这笔钱最后能不能在报表上收回成本，得等它上市之后才有数据。</p>
<h3>参考资料</h3>
<ul>
<li><a href="https://www.163.com/dy/article/L9RNRDAR051180F7.html">智东西：1000亿！曝DeepSeek拿下中国AI最大融资</a></li>
<li><a href="https://www.163.com/tech/article/L9TL8A8P00097U7T.html">网易智能：OpenAI估值打折，AI还在吸金，投资人没那么上头了</a></li>
</ul>
]]></content>
    <author><name>伊橙</name></author>
    <category term="观察"/>
  </entry>
  <entry>
    <title>AI 成了加息的理由</title>
    <link href="https://cnyicheng.top/posts/ai-and-interest-rates/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/ai-and-interest-rates/</id>
    <published>2026-10-23T04:40:00.000Z</published>
    <updated>2026-10-23T04:40:00.000Z</updated>
    <summary>美国九月 CPI 反弹到 3.7%，市场开始押注十二月加息。券商给加息列的理由里有一条是 AI：资本开支抬高潜在增速、抢走上游产能、还靠债融资。三条串起来，正好解释了为什么利率一变，AI 资产跌得最狠。</summary>
    <content type="html"><![CDATA[<p>十月十四日晚上，美国九月的 CPI 出来了，同比 3.7%。八月是 3.4%，市场原本以为它会继续往下走。</p>
<p>数据公布当晚，纳斯达克跌 1.9%，费城半导体指数跌 4.3%。第二天亚太市场跟着跌，韩国综指跌 7.57%，日经 225 跌 4.9%，上证跌 1.59%。</p>
<p>我第一反应是油价。九月的能源分项涨得凶，券商报告里也都在提这一条。翻到后面几份的时候，我看到另一个理由：AI 的资本开支正在抬升美国的潜在增速，所以利率应该待在高位。</p>
<p>这个说法让我停下来想了想。过去两年大家讨论的是降几次息、什么时候降，现在开始讨论加不加。在这条因果链里，AI 从被利率打压的那一方，变成了推高利率的那一方。</p>
<h2>1. 三个数</h2>
<p>这半个月里有三个数字值得放在一起看。</p>
<p>3.7% 是美国九月的 CPI 同比。核心 CPI 2.6%，比上个月的 2.4% 有所回升。</p>
<p>41% 是五家美国云厂商固定资产占总资产的比例，2025 年的数字，2020 年是 22%。这个数据来自中金十月六日的报告《"棉花糖"时代的美国 AI》。</p>
<p>15% 是 CoreWeave 部分债券的收益率。截至六月末，这家公司的债务与租赁义务合计超过 500 亿美元。</p>
<p>通胀、资产结构、融资成本，这三件事凑在一起，才是利率和 AI 之间真正的关系。</p>
<h2>2. AI 怎么推高通胀</h2>
<p>华福证券在十月十五日的报告里写得很直接：AI 的大规模资本开支在对冲传统消费的低迷，抬升了美国的潜在经济增速，这为美联储维持高利率提供了现实基础。</p>
<p>按这个思路走下去，传导有三段。</p>
<p>先是投资本身。建数据中心是实打实的固定资产投资，会计入 GDP。一个行业每年砸几千亿美元进去，宏观数字一定会动。</p>
<p>然后是上游产能。卡、内存、电力、变压器、光纤，产能都不是无限的。AI 把需求拉起来，价格就往上走。存储是最好的例子：力积电第三季度毛利率从 28% 升到 48%，产能利用率从 87% 升到 97%，管理层说 2027 年逻辑代工还要再涨 10% 到 20%。这是 AI 需求传导到上游报价的直接证据。</p>
<p>最后是融资，放到下一节讲。</p>
<p>有意思的是，贝莱德的芬克给了一个方向相反的说法。他在十月二十一日说，如果数据中心建得不够快，算力成本会更高，那才更有通胀性。按他的逻辑，供给不足才是通胀的来源，建设是解药。</p>
<p>两种说法不冲突。短期看，建设抢产能，推高价格；长期看，产能上来，价格才下来。中间这几年，通胀就是黏的。</p>
<table>
<thead>
<tr>
<th>传导</th>
<th>机制</th>
<th>能看到的证据</th>
</tr>
</thead>
<tbody>
<tr>
<td>投资</td>
<td>数据中心计入固定资产投资</td>
<td>云厂商资本开支连年上调</td>
</tr>
<tr>
<td>产能</td>
<td>卡、内存、电力被抢</td>
<td>力积电三季度毛利率 28% → 48%</td>
</tr>
<tr>
<td>融资</td>
<td>靠债扩张，推高长端利率</td>
<td>CoreWeave 债券收益率约 15%</td>
</tr>
</tbody>
</table>
<h2>3. 钱是借来的</h2>
<p>一个 AI 数据中心项目的现金流长这样：先借钱，买卡，建机房，通电；然后签长约，把容量卖出去；收入要到第三年、第五年才慢慢回来。</p>
<p>这个形状决定了它对利率极其敏感。折现率往上走一点，越远的现金流缩得越狠。</p>
<p>CoreWeave 是这套结构最清楚的样本。它的债务和租赁义务合计超过 500 亿美元，部分债券的收益率在 15% 附近，十月二十二日股价又跌了约 10%。同一天，高盛说它的融资缺口是 230 亿美元，伯恩斯坦说是 760 亿。两家对同一家公司的缺口算出差三倍的数字，说明这笔账本来就难算。</p>
<p>往上看，Meta 披露的未来支出承诺约 8250 亿美元。Anthropic 在招股书里预计十年内为云服务、算力和基础设施支出至少 5180 亿美元，其中约八成不可取消，不管最后用没用都要付。</p>
<p>中金的判断是，美国 AI 的金融化还在早期，头部企业的筹资现金流刚转正，新增融资以债权为主，暂时看不到系统性风险。他们也提醒，短期流动性风险不能忽视。</p>
<p>我的读法是，这类公司越来越像重资产公用事业，而不是软件公司。</p>
<table>
<thead>
<tr>
<th></th>
<th>软件公司</th>
<th>AI 基础设施公司</th>
</tr>
</thead>
<tbody>
<tr>
<td>主要资产</td>
<td>代码和人</td>
<td>机房、卡、电</td>
</tr>
<tr>
<td>收入确认</td>
<td>订阅，按月</td>
<td>长约，按年</td>
</tr>
<tr>
<td>融资方式</td>
<td>股权、自有现金流</td>
<td>抵押贷款、租赁、债券</td>
</tr>
<tr>
<td>利率敏感度</td>
<td>低</td>
<td>高</td>
</tr>
</tbody>
</table>
<p>这张表也能解释，为什么同样是科技股，跌起来差别那么大。</p>
<h2>4. 三种泡沫论</h2>
<p>十月二十日，城堡投资的格里芬说，这轮 AI 热潮"几乎肯定"已经形成资本泡沫。他没有因此否定 AI，而是拿互联网泡沫做对照：重大技术变革往往伴随资本过度投入，泡沫另一侧会留下少数长期赢家。</p>
<p>十月十八日，橡树资本的马克斯说他目前不认为市场到了真正的泡沫状态。理由是泡沫的特征之一是投资者对风险浑然不觉，而现在华尔街上下都在追问是不是泡沫，这种浑然不觉恰好不在。</p>
<p>芬克讲的是第三件事，建得太慢才是问题。</p>
<p>三个人的说法听着矛盾，其实在回答三个不同的问题。格里芬在算资本的回报率，马克斯在看市场心理，芬克在讲实物供给的约束。三个问题可以同时成立。</p>
<p>所以我很少用"是不是泡沫"来给自己下判断。更有用的问题是，这些资产的现金流，能不能覆盖 15% 的债。这个问题有答案，前面那个没有。</p>
<h2>5. 国内为什么跌得少</h2>
<p>十月十五日那天，上证跌 1.59%，是全球主要市场里跌幅最小的之一。今天上午沪指跌 2%，科创 50 跌 3%，跟上了外面的节奏，幅度还是温和。</p>
<p>传导路径其实就两条。</p>
<ul>
<li>汇率和跨境资金：美元资产收益率上去，北向资金可能重新配置，人民币承压。</li>
<li>无风险利率：全球长端利率上行，高估值成长股要重新折现。</li>
</ul>
<p>这两条都不新鲜。决定 A 股中期走势的，还是国内的货币政策和企业的盈利。今年社保、年金、保险这些中长期资金净买入 A 股超过 6000 亿元，持有流通市值比 2025 年底增长 12.5%。外资在 A 股流通盘里占约 5%，这个比例不足以单独决定方向。</p>
<p>反面的情形是，如果十二月真的加了，油价继续待在高位，压力会从估值转到汇率，那时候"以我为主"要付的成本会变高。</p>
<h2>6. 我会盯什么</h2>
<ol>
<li><strong>十二月的议息会议。</strong> 现在的一致预期是十月按兵不动、十二月加 25 个基点。如果十二月也不动，这一轮的加息定价就该退潮。</li>
<li><strong>CoreWeave 的债券利差。</strong> 15% 是恐慌定价还是合理定价，看它会不会随着股价反弹收窄。</li>
<li><strong>DRAM 和 NAND 的现货价。</strong> 存储涨价是这轮通胀里最"AI 特有"的部分，它见顶的时间点比股价更能说明问题。</li>
<li><strong>云厂商下一季度的资本开支指引。</strong> 这是整条链条的源头，它一放缓，后面的债、电、卡都会跟着松。</li>
</ol>
<p>我不打算猜十二月加不加。我更好奇的是，如果真的加了，哪一家会第一个把资本开支指引往下调。</p>
<h3>参考资料</h3>
<ul>
<li><a href="https://www.stcn.com/article/detail/4221185.html">证券时报：美联储加息预期升温，难改A股中长期运行趋势</a></li>
<li><a href="http://finance.china.com.cn/industry/20261022/6326498.shtml">中国网财经：AI泡沫之辩，重演互联网狂热还是押注长期赢家</a></li>
<li><a href="https://finance.sina.com.cn/roll/2026-10-18/doc-inivrrfp7851370.shtml">新浪财经：全球AI交易生变，"棉花糖"融化还是夏日雷雨</a></li>
<li><a href="https://fund.eastmoney.com/a/202610233904618534.html">每日经济新闻：力积电预计明年逻辑代工再涨价10%至20%</a></li>
<li><a href="https://finance.eastmoney.com/news/11228,202610233904892125.html">东方财富：黄仁勋谈内存供给，2029年过剩可能性不大</a></li>
</ul>
]]></content>
    <author><name>伊橙</name></author>
    <category term="观察"/>
  </entry>
  <entry>
    <title>Agent 项目常用模板：SseEmitter 流式返回</title>
    <link href="https://cnyicheng.top/posts/agent-sse-streaming-template/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/agent-sse-streaming-template/</id>
    <published>2026-10-10T09:00:00.000Z</published>
    <updated>2026-10-10T09:00:00.000Z</updated>
    <summary>Agent 对话接口的流式返回被问了很多轮，这里整理成一份可复用的 SseEmitter 模板：返回类型、异步执行、事件命名、data 结构、正常与异常收尾，以及前端怎么接。</summary>
    <content type="html"><![CDATA[<p>我在写一个 Agent 项目，按 M1 到 M4 分期推进。M1 要落地的第一块是对话接口的流式返回：用户发一句话进来，后端把路由结果、工具调用、回答正文和 token 用量这几类事件陆续推给前端。</p>
<p>这块逻辑我前前后后问了很多轮，问题散在类型声明、异步执行、事件命名、data 结构、收尾方式这些点上，索性整理成一份模板。代码是模板级的伪代码，接口名和字段按你项目里的实际命名替换。</p>
<h2>1. 这类接口为什么用 SSE</h2>
<p>一次 Agent 问答要经过路由、工具调用、检索和生成，中间会跨好几秒甚至几十秒。前端如果只拿得到最终结果，用户全程都在盯着一个转圈图标。</p>
<p>SSE 是服务端单向推，浏览器用原生 <code>EventSource</code> 就能接，Agent 对话的数据流向刚好是单向的。</p>
<table>
<thead>
<tr>
<th>需求</th>
<th>选什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>单向推送、文本事件流</td>
<td>SSE</td>
</tr>
<tr>
<td>前端也要频繁主动推</td>
<td>WebSocket</td>
</tr>
<tr>
<td>一次性返回</td>
<td>普通 REST 接口</td>
</tr>
<tr>
<td>大文件、二进制流</td>
<td><code>StreamingResponseBody</code> 或文件下载</td>
</tr>
</tbody>
</table>
<p>可能你会想：既然要推送，那直接上 WebSocket 不就行了。Agent 对话里双向通信的场景很少，多出来的能力反而要自己处理心跳、重连和帧解析。SSE 走普通 HTTP，重连交给浏览器，后端按事件格式往外写就行。</p>
<h2>2. 完整模板代码</h2>
<pre><code>import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import jakarta.validation.Valid;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

@RestController
@RequestMapping("/agent")
public class AgentStreamController {

    private final ChatService chatService;
    private final Executor agentExecutor;   // 建议自定义线程池，别用 commonPool

    public AgentStreamController(ChatService chatService, Executor agentExecutor) {
        this.chatService = chatService;
        this.agentExecutor = agentExecutor;
    }

    /**
     * SSE 流式对话接口
     *
     * 关键点：
     * 1. produces = text/event-stream，告诉 Spring 这是流式响应
     * 2. 返回类型必须是 SseEmitter，Spring 才会走 SSE 处理、建立通道
     * 3. 真正的耗时逻辑放到异步线程，避免占用 Tomcat 请求线程
     * 4. 通过 emitter.send 多次推送事件，最后 complete 关闭
     */
    @PostMapping(
            value = "/chat/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE
    )
    public SseEmitter chatStream(@Valid @RequestBody ChatRequest request) {

        // ① 创建 emitter，设置超时（0L 表示永不超时）
        SseEmitter emitter = new SseEmitter(120_000L);

        // ② 注册生命周期回调，便于排查客户端断开、超时等情况
        emitter.onCompletion(() -&gt; log.info("SSE completed, session={}", request.getSessionId()));
        emitter.onTimeout(()    -&gt; log.warn("SSE timeout, session={}", request.getSessionId()));
        emitter.onError(e      -&gt; log.warn("SSE error, session={}, msg={}", request.getSessionId(), e.getMessage()));

        // ③ 异步执行，避免阻塞 Tomcat 请求线程
        CompletableFuture.runAsync(() -&gt; {
            try {
                // ===== 1. route 事件：通知前端路由到哪个 agent =====
                emitter.send(SseEmitter.event()
                        .name("route")
                        .data(Map.of("agent", "code_agent")));

                // ===== 2. step 事件：通知前端当前执行步骤 =====
                Map&lt;String, Object&gt; stepData = new HashMap&lt;&gt;();
                stepData.put("type", "tool_call");
                stepData.put("tool", "search_code");
                stepData.put("args", Map.of("query", request.getMessage()));
                emitter.send(SseEmitter.event()
                        .name("step")
                        .data(stepData));

                // ===== 3. 真正执行业务逻辑 =====
                ChatResponse response = chatService.chat(request);

                // ===== 4. answer 事件：推送回答正文与引用 =====
                emitter.send(SseEmitter.event()
                        .name("answer")
                        .data(Map.of(
                                "content",   response.getContent(),
                                "citations", response.getCitations()
                        )));

                // ===== 5. done 事件：推送统计信息并正常结束 =====
                Map&lt;String, Object&gt; usage = new HashMap&lt;&gt;();
                usage.put("promptTokens",     response.getPromptTokens());
                usage.put("completionTokens", response.getCompletionTokens());
                usage.put("totalTokens",      response.getTotalTokens());
                usage.put("latencyMs",        response.getLatencyMs());

                Map&lt;String, Object&gt; doneData = new HashMap&lt;&gt;();
                doneData.put("traceId", response.getTraceId());
                doneData.put("usage",   usage);

                emitter.send(SseEmitter.event()
                        .name("done")
                        .data(doneData));

                // ===== 6. 正常结束 =====
                emitter.complete();

            } catch (Exception e) {
                // ===== 7. 异常兜底：尽量推一个 error 事件 =====
                try {
                    emitter.send(SseEmitter.event()
                            .name("error")
                            .data(Map.of(
                                    "message",
                                    e.getMessage() == null ? "Agent 执行失败" : e.getMessage()
                            )));
                } catch (IOException ignored) {
                    // 客户端可能已断开，忽略
                } finally {
                    emitter.completeWithError(e);
                }
            }
        }, agentExecutor);

        // ④ 关键：必须 return emitter，Spring 才能绑定响应、建立 SSE 通道
        return emitter;
    }
}
</code></pre>
<h2>3. 返回类型得是 SseEmitter</h2>
<p>Spring 靠方法声明的返回类型挑处理器。声明成 <code>SseEmitter</code>，才会走 <code>SseEmitterReturnValueHandler</code>，把响应设成 <code>text/event-stream</code>、保持连接打开、绑定 emitter。</p>
<p>换成 <code>Object</code>、<code>String</code> 或者 <code>void</code>，请求会走普通处理那一套，SSE 通道根本没建起来，异步线程里的 <code>send</code> 也就发不出去。</p>
<h2>4. return emitter</h2>
<p><code>return</code> 是方法唯一的出口，只执行一次。</p>
<p>它把 emitter 交给 Spring，Spring 在 return 之后才把 emitter 和 HTTP 响应绑到一起。异步线程里的 <code>send</code> 要等绑定完成才会真正写到响应上。返回 null 或者别的对象，通道都建立不起来。</p>
<p>:::important 两件事的顺序
Tomcat 请求线程先 return emitter，Spring 再绑定通道，异步线程才能 send。
:::</p>
<h2>5. 耗时逻辑交给异步线程</h2>
<pre><code>CompletableFuture.runAsync(() -&gt; { ... }, agentExecutor);
</code></pre>
<p>Tomcat 请求线程跑的是建立通道这一步，几秒到几十秒的 Agent 逻辑丢给它，会把线程池占满。</p>
<p>放到异步线程之后，请求线程在 return 那一刻就释放了，SSE 连接由 emitter 自己维持。线程池建议自己建一个，默认的 <code>ForkJoinPool.commonPool</code> 在并发上来之后不够用。</p>
<h2>6. 事件名管分类，data 管内容</h2>
<pre><code>SseEmitter.event().name("route").data(Map.of("agent", "code_agent"))
</code></pre>
<p><code>.name()</code> 决定前端按哪个事件名分流，<code>.data()</code> 是这一帧的负载，Spring 用 Jackson 把它序列化成 JSON。</p>
<p>Agent 场景里常用的五类事件：</p>
<table>
<thead>
<tr>
<th>事件名</th>
<th>时机</th>
<th>data 内容</th>
<th>前端用途</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>route</code></td>
<td>开始</td>
<td><code>{agent}</code></td>
<td>显示路由到哪个 agent</td>
</tr>
<tr>
<td><code>step</code></td>
<td>执行中</td>
<td><code>{type, tool, args}</code></td>
<td>显示步骤条或工具调用</td>
</tr>
<tr>
<td><code>answer</code></td>
<td>出结果</td>
<td><code>{content, citations}</code></td>
<td>渲染回答正文</td>
</tr>
<tr>
<td><code>done</code></td>
<td>结束</td>
<td><code>{traceId, usage}</code></td>
<td>停止加载、显示用量</td>
</tr>
<tr>
<td><code>error</code></td>
<td>异常</td>
<td><code>{message}</code></td>
<td>显示错误、停止加载</td>
</tr>
</tbody>
</table>
<p><code>.name()</code> 是可选项，但前端要靠它分流，写上是省事的选择。</p>
<h2>7. 推送顺序</h2>
<pre><code>sequenceDiagram
    autonumber
    participant F as 前端
    participant T as Tomcat 请求线程
    participant A as 异步线程
    F-&gt;&gt;T: POST /agent/chat/stream
    T--&gt;&gt;F: 返回 SseEmitter，Spring 绑定通道
    Note over T: 请求线程释放
    A-&gt;&gt;F: event: route
    A-&gt;&gt;F: event: step
    Note over A: chatService.chat() 在这里执行
    A-&gt;&gt;F: event: answer
    A-&gt;&gt;F: event: done
    A-&gt;&gt;F: complete()
</code></pre>
<p><code>route</code> 和 <code>step</code> 是过程通知，不依赖业务结果，可以早发。<code>answer</code> 和 <code>done</code> 的 data 来自 <code>chatService.chat()</code> 的返回值，得等它返回。所以中间那段耗时逻辑删不掉，这几帧也合并不了一帧。</p>
<h2>8. 异常兜底与收尾</h2>
<p>Agent 执行可能超时、检索失败、模型报错，整个流程要用 try-catch 包住。</p>
<p>出错时尽量推一个 <code>error</code> 事件出去，让前端有明确反馈，而不是永远转圈。推 <code>error</code> 本身也可能失败，客户端断开的时候就会抛 <code>IOException</code>，所以在 catch 里再包一层 try-catch。</p>
<p>收尾分两种：正常走 <code>complete()</code>，异常走 <code>completeWithError(e)</code>。只 send 不关闭，连接会一直挂着。</p>
<h2>9. 前端怎么接</h2>
<pre><code>const es = new EventSource('/agent/chat/stream');  // 或 fetch + ReadableStream

es.addEventListener('route',  e =&gt; showStatus(`路由到 ${JSON.parse(e.data).agent}`));
es.addEventListener('step',   e =&gt; appendStep(JSON.parse(e.data)));
es.addEventListener('answer', e =&gt; renderAnswer(JSON.parse(e.data)));
es.addEventListener('done',   e =&gt; { stopLoading(); console.log(JSON.parse(e.data).usage); });
es.addEventListener('error',  e =&gt; showError(JSON.parse(e.data).message));
es.onerror = () =&gt; es.close();
</code></pre>
<p>前端只管挂监听器，后端 send 一次就触发一次对应回调。通道一直开着，直到 <code>complete()</code> 关上。</p>
<h2>10. 什么场景别用</h2>
<table>
<thead>
<tr>
<th>场景</th>
<th>更合适的方案</th>
</tr>
</thead>
<tbody>
<tr>
<td>前端也要频繁主动推数据</td>
<td>WebSocket</td>
</tr>
<tr>
<td>数据量极大、二进制流</td>
<td><code>StreamingResponseBody</code> 或文件下载</td>
</tr>
<tr>
<td>只要一次返回</td>
<td>普通 <code>@RestController</code></td>
</tr>
</tbody>
</table>
<p>第三种情况硬上 SSE，多出来的是一套事件协议和一段异步收尾逻辑，收益基本为零。</p>
<h2>11. 速记卡</h2>
<pre><code>【SseEmitter 模板速记】

接口签名:
  @PostMapping(produces = TEXT_EVENT_STREAM_VALUE)
  public SseEmitter xxx(@RequestBody Req req)

方法体:
  1. new SseEmitter(timeout)          ← 造通道
  2. 注册 onCompletion/onTimeout/onError
  3. CompletableFuture.runAsync(() -&gt; {
         try {
             send(route)                ← 过程通知
             send(step)                 ← 过程通知
             resp = service.call(req)   ← 真正耗时逻辑
             send(answer)               ← 结果通知（依赖 resp）
             send(done)                 ← 结束通知（依赖 resp）
             complete()                 ← 正常关闭
         } catch (Exception e) {
             send(error)                ← 异常兜底
             completeWithError(e)       ← 异常关闭
         }
     }, executor)
  4. return emitter                    ← 必须！交给 Spring 建通道

事件规范:
  .name("route" | "step" | "answer" | "done" | "error")
  .data(Map / DTO)                     ← 会被序列化成 JSON

三条铁律:
  ① 返回类型必须是 SseEmitter
  ② 必须 return emitter
  ③ 正常 complete / 异常 completeWithError
</code></pre>
<h2>12. 后面还有什么</h2>
<p>M1 阶段的第一部分先放这些能直接复用的内容。同一条线上的后续我按顺序往下写：</p>
<ul>
<li>事件协议怎么定版本、加字段时不破坏前端</li>
<li><code>answer</code> 拆成 <code>token</code> 事件做出打字机效果</li>
<li>断线重连和 <code>Last-Event-ID</code> 续传</li>
<li>客户端主动中断时后端的资源回收</li>
<li>多实例部署时 SSE 连接怎么落到同一个节点</li>
</ul>
<p>返回 <code>SseEmitter</code> 建通道，异步线程多次 <code>send</code> 推事件，最后 <code>complete</code> 关掉。name 分类，data 装内容，异常兜底，return 交权，写 Agent 流式接口要落的就这几步。</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>买得起BBA，不等于养得起BBA</title>
    <link href="https://cnyicheng.top/posts/buy-bba-not-afford-bba/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/buy-bba-not-afford-bba/</id>
    <published>2026-10-08T08:39:15.000Z</published>
    <updated>2026-10-08T08:39:15.000Z</updated>
    <summary>国庆回家给老爹当司机，从GLE450、本田URV聊到E300L，顺便算了一笔养车账。</summary>
    <content type="html"><![CDATA[<p>买得起BBA，不等于养得起BBA。<br />
这句话我以前听过，但直到今年国庆，我才算真正有了体会。</p>
<p>国庆回家，我给老爹当司机。驾照刚考下来，给父亲开车，在我看来是理所当然的事。老爹有一台GLE450，我爸知道我不敢开，所以没让我碰。我也没逞强，转身拿了老妈那台本田URV的钥匙。</p>
<p>URV车身宽大，坐姿比轿车高，方向盘偏轻，开起来没有看起来那么笨重。我调好座椅，老爹坐进副驾，后座还坐着其他人。我们出发去山阳。</p>
<p>路上总得说话，聊着聊着就聊到了车。老爹看着前方，语气很平常：“等你毕业，或者结婚的时候，给你买一台E300L豪华吧。这车也配得上你。”</p>
<p>我盯着前面的路，没敢立刻接话。倒不是怕显得我太想要。我喜欢E300L这件事，家里早就知道，根本不是秘密；只是车里还坐着其他人，当着外人的面答应，我有点不好意思，好像我多迫不及待要老爹给我买车似的。</p>
<p>E300L我确实从高中就喜欢。以前跟老爹出门，路上只要闪过一辆E300，我会立刻停下正在说的话题，眼睛跟着它走，直到它拐弯或者被车流挡住。那时候我没驾照，对车的喜欢也停在表面。我只知道这车内饰好看，晚上氛围灯亮起来很优雅，开起来很从容。保险、保养、油耗、停车费这些，我根本没想过。</p>
<p>直到这次从山阳回来，我才认真算了一笔账。保险、保养、油费，加上一线城市的停车费，各种开支摊到每个月，大概两千到三千。然后手机开始给我推各种视频，封面上的字很吓人：月入一万，慎入。视频一个接一个，博主们列账单、画图表，说得头头是道。我看了半天，心里那点喜欢慢慢冷静下来。</p>
<p>按一个大四学生出去实习，或者刚毕业初入社会那点实习工资来算，想养E300L，确实不太现实。</p>
<p>于是我又去看别的车。亚洲龙、雷克萨斯、宝马三系、凯迪拉克CT5。看来看去总能找到槽点：外观好看的内饰差点意思，内饰好看的外观又小气。CT5外观内饰我都挺喜欢，可它那个“仁王盾”喝油不是一般的厉害，后排空间也难受。最后我只能承认，BBA不坑穷人。穷人连被坑的资格都没有。</p>
<p>但转头我又骂自己没出息。一个二十出头的人，连每个月两千块的养车钱都怕，以后还能干什么？更何况我根本不用背贷款。家里对我的托举已经够多了。家里在我名下买了两套房，都是全款给我的，一是有能力一次性付清，二是怕我负担大。这已经充分证明，家里真的很惯我。</p>
<p>所以我会想，如果我家境没那么好呢？如果毕业以后房贷车贷都压在身上呢？难道我也要因为一辆E300L的养车成本，就对所有要背的东西退缩？</p>
<p>我记得有种说法，一个人可能攒不下一百万，但绝对能还清一百万。以前我没太当回事，现在倒有些认同。哪怕视频里都在说月入一万养不起E300L，我算完账还是觉得，两千多块没有那么夸张。我可以把它当成毕业后的第一份挑战。否则车和房都由家里全款买了，连养车都还要靠家里，那和废物有什么区别。</p>
<p>后来我想，我一开始觉得养不起，可能不全是钱的问题。我从小家里条件其实没那么好，那种匮乏感会留在性格里，变成一种不高的配得感。比如之前找实习，线上有个老板给我开一天一百二。钱不算多，可我打心底觉得自己不配靠技术赚钱。但道理上讲，我的产出肯定超过一百二，否则没人会倒贴钱招实习生。奇怪的是，这种不配得感在其他地方不太明显，偏偏在薪资上特别严重。大概是我对自己的技术栈和掌握深度不够自信，对整个行业也不够自信。</p>
<p>再往深里想，这种不配得感可能还有一部分原因：家里把我保护得太好了。我从小没去打过工，没真正靠自己的手赚过钱，所以在我这里，“赚钱”一直是一件难度很高的事。人对自己没接触过的事情，总会先退一步。换一个从小就要自己挣零花钱、自己找活干的环境，我大概不会是这个心态。不过环境影响人，这样的性格我也没什么可抱怨的。以后改就是了。</p>
<p>说到不自信，其实我的学生时代也没什么值得炫耀的。小学、初中、高中，我从来都不是学霸。相反，我学得还算认真，成绩却常年排在年级40%到50%之间。现在回头看，我一直挺老实，成绩却不靠前，大抵是努力了，但没努力到点子上。英语常年在及格线附近；数学我自称学得不错，却也只能考到110；物理常常只有60；化学更不算擅长。越学越没自信，最后我把这些都归结为天赋。可能人的智商和天赋确实有上限，直到现在，我还认为这个说法有一定道理。</p>
<p>其实英语差这件事挺冤的。我小学时英语很好，但有一次上课，我和同学在课上扔纸球。等我反应过来时，语法已经听不懂了；再一抬头，大半年过去了。如果当时没扔那个纸球，我的英语会不会一直好下去？高中开始练口语，大学出国留学？虽然也考虑过出国，但我的English确实有点poor。之后真要去，可能只考虑日本、俄罗斯这些国家了。意大利或者英国，我也很想去。算了，真有必要的话，我会继续学英语的。</p>
<p>跑题了，说回刚刚的话题。</p>
<p>我不认为IT还能像2018年那样遍地黄金。2026年的IT在AI的影响下半死不活，对初级开发者尤其致命。我没有信心保证，自己现在学的东西十年后还能准时换成一份工资。但话说回来，这些也都是胡思乱想。汽车出现后，拉车的师傅失业了，可司机这个职业也跟着诞生了。无论如何，只要肯学、肯干，甚至肯出力，总还是能在社会上找到立足之地。实在不行，去日本或者美国出卖劳动换报酬，再回国花，这也能算一条求生思路。这里不赘述了。</p>
<p>写到这里，我也说不清文章到底讲了什么。我只是想吐槽一下自己的懦弱，顺便感慨BBA养车真贵。不知不觉写了这么多，想说的也都说到了。</p>
<p>后来有人问我，家境好是不是可以躺平。我说句反常识的：不太能，起码刚达到A8的家境，还不足以让你彻底放下一切。也可能我的思维一直停留在家里比较贫寒的2015年。我始终认为，人的立身之本从来不是家底，而是自己手上那点能拿去社会上换饭吃的技术。</p>
<p>我这人有毛病，喜欢拿别人的长处比自己的短处。看到技术好的人，我羡慕他的技术，忽略他的不足；看到情商高的人，我感慨他的情商，忘记他花钱大手大脚。面对我父亲时，这个毛病更严重。我很佩服他一生三起三落，仍然能给家里提供优渥的环境。换成我，可能第一次落下去就撂挑子了，跑去出苦力还债。可他撑住了。我一直想，打工无法大富大贵，但在金矿时代结束以后，这真的是二代们最好的归宿。越有钱的老板越在意体面，那我自然想做好自己的工作。一部分为了我自己，更多是为了老爹在外人面前提起我时，能因为我而体面、自豪。</p>
<p>我觉得写的有点装了本来想改一改，但是有朋友跟我说，都写自己了，还不装点？ 虽然没有这个意思但是文章内容确实是我之前在思考的，就是想到哪写到哪了，看起来装点就装点吧，做自己</p>
<p>在此感谢ohh同学为博客提供的建议以及，在我创建博客以及之前写博客内容的时候给予时间帮助（什么帮助先别管反正就是帮助了）</p>
<p>扯远了，哥们。就写到这里吧。最后附上几张图：</p>
<p><img src="./dad-voice-1.png" alt="老父亲语音转文字截图1" /></p>
<p><img src="./dad-voice-2.png" alt="老父亲语音转文字截图2" /></p>
<p><img src="./dad-voice-3.jpg" alt="附图3" /></p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="日常"/>
  </entry>
  <entry>
    <title>一句话，两支宣传片</title>
    <link href="https://cnyicheng.top/posts/one-prompt-two-promo-videos/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/one-prompt-two-promo-videos/</id>
    <published>2026-10-03T00:00:00.000Z</published>
    <updated>2026-10-03T00:00:00.000Z</updated>
    <summary>在 Claude Code 里给 Opus 5.5 一句提示词，它先给 cnyicheng.top 拍了一支雨夜 lo-fi 风格的宣传片；我又补了一段风格描述，它换出第二支动漫 OP。视频和配乐都是它写代码生成的，成片太大放不进来，这里只贴两张关键画面截图。</summary>
    <content type="html"><![CDATA[<p>今天下午我在 Claude Code 里敲了一段提示词，让它给 cnyicheng.top 做个宣传视频。提示词很短，就是给我的网站做一个宣传视频，域名是 cnyicheng.top。后面补了两句交代，音乐和内容它自己构思，关键画面先发给我确认，成品放到 <code>/Library/CC_Opus5.5</code>。</p>
<p>它没再追问。先抓了一遍网站，文章标题、标签、番剧列表和页面截图都过了一遍，然后搭了一个 Remotion 工程，把分镜、转场、特效写成代码，又写脚本合成配乐，最后渲染成 mp4。</p>
<p>站内每篇文章都有体积限制，第二版的成片是 97MB，传不上来，下面只放两支片子的关键画面截图。</p>
<h2>第一支：雨夜 lo-fi</h2>
<p>第一版走的是雨夜 lo-fi 路线。背景是紫黑色的夜雨，主视觉是玻璃拟态卡片和粉色霓虹，字幕踩着打字机的节奏一条条出现，镜头全程缓慢推近。</p>
<p>配乐是 96 BPM 的 lo-fi 拍子，下面垫了一层雨声。画面从“深夜第 N 次搜索多 Agent 怎么协作”起手，转到网站标题、关键词云、精选文章、数据面板、追番墙，最后落到 CTA 和“诸事顺，利”。</p>
<p>整支片子 53 秒，先出了一版 720p 草稿。看完的第一感觉是它听懂了需求，节奏和信息点都稳，只是整体偏规矩，一段接一段，没有太多意外。</p>
<p><img src="./v1-key-frames.webp" alt="第一版宣传片的关键画面总览：雨夜 lo-fi 风格" /></p>
<p><em>图：第一版的关键画面，从雨夜开场、关键词云、文章精选到数据面板。</em></p>
<h2>第二支：动漫 OP</h2>
<p>看完第一版我补了一段话，说现在的视频太规矩有序了，再创新一些，塞点艺术感的特效，有活力一点，二次元感拉满，像动漫 CG 一样，让人眼前一亮，觉得这个网站很高级，最后加一句“本视频由 Opus 5.5 生成”。</p>
<p>:::tip 第二版补的提示词
你现在做的视频太规矩有序了，再创新一些，塞入艺术些的特效，有活力些，二次元感拉满，像动漫 CG 一样，视频效果让人眼前一亮，让人感觉到这个网站很高级很厉害，你的模型效果也很好，最后加上一句：本视频由 Opus 5.5 生成。
:::</p>
<p>然后它换了个完全不同的画风。开场是终端窗口里敲那句提示词，回车之后镜头怼进屏幕，切进动漫 OP：漫画分镜、速度线、半调网点、荧光粉配青色，画面跟着鼓点震。结尾先是一个漫画式定格，箭头框里写着 To Be Continued，再切回终端打出“本视频由 Opus 5.5 生成”的署名。</p>
<p>配乐也换成了 120 BPM 的 future bass，走 IV–V–iii–vi 的王道进行。这一版直接出了 1920×1080 的成片，60 秒。</p>
<p><img src="./v2-key-frames.webp" alt="第二版宣传片的关键画面总览：动漫 OP 风格" /></p>
<p><em>图：第二版的关键画面，终端开场、漫画分镜、文章推荐、数据冲击和结尾。</em></p>
<h2>配乐</h2>
<p>两支片子的配乐都不是现成素材。它写了两个 Node 脚本，用振荡器、包络和滤波器把波形一段段算出来，第一版是 lo-fi 雨夜的感觉，第二版是动漫 OP 的路子。</p>
<p>第一次听的时候我觉得挺神奇，画面和音乐是在同一次对话里一起生成的。刷了它做的几支视频之后，AI 味就出来了：鼓组和和声进行偏套路，情绪从开头一路推到底，该留白的地方不太舍得停。当背景音乐用完全够，单独拿出来听还是不够耐听。</p>
<h2>代价</h2>
<p>效果好的代价是烧钱。每改一次分镜，都要重新出静帧、对时间轴、再走一遍渲染，token 和时间都实打实花出去了。第二版为了 1080p 的成片，音轨给到 320k，H.264 文件 97MB，工程量并不小。</p>
<p>以现在的价格看，这个玩法更适合偶尔尝鲜，天天这么跑钱包受不了。</p>
<h2>半年之后</h2>
<p>Opus 5.5 这次的表现让我有点改观：一句提示词加一段风格描述，它就能把分镜、特效、配乐、剪辑串成一条流水线，最后交出来的东西已经不是“能看”的水平了。</p>
<p>音乐这块再过半年，大概率会落到便宜得多的模型上。到那时候，我大概会把它当成写博客的常规工具，而不是现在这种隔一阵子花笔钱尝个鲜。</p>
<p>两个 mp4 都放在 <code>/Library/CC_Opus5.5</code> 里，有空再让它改一版。</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="日常"/>
  </entry>
  <entry>
    <title>追番：俗称盾之勇者的哥们，但是没有盾勇憋屈</title>
    <link href="https://cnyicheng.top/posts/zhui-fan-shield-hero/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/zhui-fan-shield-hero/</id>
    <published>2026-09-27T11:53:07.000Z</published>
    <updated>2026-09-27T11:53:07.000Z</updated>
    <summary>牛逼兄弟，什么叫你非得说重骑士大后期是轮椅所以选这个职业，漫画都大后期了你怎么还在操作，又是操作怪硬说自己是数值轮椅的，但是有一说一这家公司还是喜欢意义不明的专场和粒粒分明的发丝，看起来很好看啊很好看，很久没有看到这种异世界金厕纸了</summary>
    <content type="html"><![CDATA[<p>追番追番，盾之勇者的兄弟来了，男主好帅女主好可爱。</p>
<p>看完更新只想说：下一集快点来。</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="日常"/>
  </entry>
  <entry>
    <title>新手须知--工具分层</title>
    <link href="https://cnyicheng.top/posts/tool-layering-for-beginners/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/tool-layering-for-beginners/</id>
    <published>2026-09-27T08:30:00.000Z</published>
    <updated>2026-09-27T08:30:00.000Z</updated>
    <summary>工具一多就会乱：选错、越权、误触、审计断线。这篇从新手视角讲清楚工具为什么分层、按什么分、数据工具和动作工具的差别在哪，以及读写分离和五个风险层怎么落到代码里。</summary>
    <content type="html"><![CDATA[<p>刚上手写 Agent 的时候，我把能想到的能力都写成工具，一次性交给模型：查订单、改地址、退款、发邮件、跑 SQL，全塞在同一个列表里。</p>
<p>工具过十个之后就开始出问题。让它查物流，它去查了订单；该走审批的退款，它直接调了。</p>
<p>有人问过我三个问题：工具为什么需要分层、分层的概念是什么、数据工具和动作工具有什么不同。这三个问题在同一条线上，我按顺序讲。</p>
<h2>1. 工具一多会怎样</h2>
<p>假设一个客服 Agent 挂了 50 个工具：查订单、查物流、查库存、退款、改地址、删订单、发邮件、查数据库、执行 SQL、转账、改配置、重启服务。</p>
<p>平铺在同一个列表里，会同时出现七个问题：</p>
<table>
<thead>
<tr>
<th>问题</th>
<th>表现</th>
</tr>
</thead>
<tbody>
<tr>
<td>选择困难</td>
<td>面对 50 个工具，模型容易选错</td>
</tr>
<tr>
<td>权限混乱</td>
<td>说不清哪个工具谁能调</td>
</tr>
<tr>
<td>风险失控</td>
<td>读和写混在一起，危险操作容易被误触</td>
</tr>
<tr>
<td>审计困难</td>
<td>不知道谁在什么时候调了什么、为什么</td>
</tr>
<tr>
<td>成本失控</td>
<td>一次简单查询也可能走大模型</td>
</tr>
<tr>
<td>错误难定位</td>
<td>出了问题不知道是哪一类工具造成的</td>
</tr>
<tr>
<td>扩展困难</td>
<td>新增一个工具，影响面说不清</td>
</tr>
</tbody>
</table>
<p>七个问题的根是同一个：工具列表是扁平的，工具背后的风险、权限和成本一点都不扁平。</p>
<h2>2. 分层的本质</h2>
<p>分层是按职责、风险、权限、调用频率和成本把工具分组，每一组有自己的调用规则、校验强度和边界。</p>
<p>它要解决的事情有六件：</p>
<table>
<thead>
<tr>
<th>目标</th>
<th>做法</th>
</tr>
</thead>
<tbody>
<tr>
<td>隔离风险</td>
<td>读和写分开，低危和高危分开</td>
</tr>
<tr>
<td>简化选择</td>
<td>模型一次只面对一层工具</td>
</tr>
<tr>
<td>明确权限</td>
<td>不同层配不同授权</td>
</tr>
<tr>
<td>加强校验</td>
<td>越高危，校验越严</td>
</tr>
<tr>
<td>便于审计</td>
<td>每层单独记日志</td>
</tr>
<tr>
<td>方便扩展</td>
<td>新增工具归入对应层</td>
</tr>
</tbody>
</table>
<h2>3. 按风险分五层</h2>
<p>这是最常用的一条分法，五层按风险从低到高排：</p>
<p>:::steps{title="工具分层的五个风险层"}</p>
<ol>
<li>
<p><strong>只读工具</strong></p>
<p>查订单、查物流、查库存、查政策。校验只要 schema 加权限，不需要审批。</p>
</li>
<li>
<p><strong>低危写工具</strong></p>
<p>创建工单、发通知、更新备注。校验加频率限制，一般不需要审批。</p>
</li>
<li>
<p><strong>中危写工具</strong></p>
<p>改地址、改预约、取消订单。校验加业务规则，审批视情况而定。</p>
</li>
<li>
<p><strong>高危写工具</strong></p>
<p>退款、转账、删数据、改配置。校验加金额阈值和幂等，必须人工审批。</p>
</li>
<li>
<p><strong>系统级工具</strong></p>
<p>执行 SQL、重启服务、改权限。校验最严，强制人工，重要操作还要双人复核。
:::</p>
</li>
</ol>
<p>每层的门槛不一样，落到代码里就是按层配的一张规则表，见第 7 节。</p>
<h2>4. 另外两种分法</h2>
<p>风险不是唯一的分法。职责和成本也能分层，区别在于它们回答的问题不同：</p>
<p>::: collapse accordion</p>
<ul>
<li>
<p>:+ 按职责分</p>
<p>数据层负责取信息，计算层负责算结果，动作层负责改状态，编排层负责触发流程，系统层负责操作基础设施。</p>
<ul>
<li>数据层：查订单、查物流、搜索知识库。</li>
<li>计算层：计算器、汇率换算、日期推算。</li>
<li>动作层：退款、发邮件、创建工单。</li>
<li>编排层：启动工单流、转人工。</li>
<li>系统层：执行 SQL、改配置、部署。</li>
</ul>
<p>按职责分的用处是给模型一个稳定的分类线索：名字里带 <code>query</code> 的只读，带 <code>create</code>、<code>update</code>、<code>send</code> 的会改状态。</p>
</li>
<li>
<p>按调用频率和成本分</p>
<p>高频低成本的工具放在最前面，低频高成本的放在后面，需要人工的单独走一条路。</p>
<ul>
<li>高频低成本：缓存查询、本地计算。</li>
<li>低频高成本：外部 API、大模型调用。</li>
<li>需要人工：审批、复核。</li>
</ul>
<p>这一层主要管钱和延迟。缓存命中率、限流、超时、重试次数都按这一层来配。
:::</p>
</li>
</ul>
<h2>5. 数据工具和动作工具</h2>
<p>这是工具分层里最重要的一组区分。</p>
<p>数据工具只读取信息，不改变系统状态。查订单、查物流、查库存、查政策、查用户信息、搜索知识库都属于这一类。</p>
<p>动作工具会改变系统状态，产生副作用 <a href="%E5%89%AF%E4%BD%9C%E7%94%A8%E6%8C%87%E7%9A%84%E6%98%AF%E8%BF%99%E6%AC%A1%E8%B0%83%E7%94%A8%E6%94%B9%E5%8F%98%E4%BA%86%E7%B3%BB%E7%BB%9F%E9%87%8C%E7%9A%84%E6%95%B0%E6%8D%AE%E6%88%96%E5%85%B6%E4%BB%96%E4%BA%BA%E7%9A%84%E7%8A%B6%E6%80%81%EF%BC%8C%E8%B0%83%E7%94%A8%E6%96%B9%E6%8B%BF%E5%88%B0%E7%9A%84%E8%BF%94%E5%9B%9E%E5%80%BC%E4%B8%8D%E6%98%AF%E5%94%AF%E4%B8%80%E7%9A%84%E5%90%8E%E6%9E%9C%E3%80%82">+side-effect</a>。退款、下单、改地址、发邮件、删数据、转账、创建工单、重启服务都属于这一类。</p>
<p>两边的差别不只是「读还是写」，工程要求也不一样：</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>数据工具</th>
<th>动作工具</th>
</tr>
</thead>
<tbody>
<tr>
<td>是否改变状态</td>
<td>否</td>
<td>是</td>
</tr>
<tr>
<td>风险</td>
<td>低</td>
<td>高</td>
</tr>
<tr>
<td>是否可逆</td>
<td>通常可逆，再查一次就行</td>
<td>可能不可逆</td>
</tr>
<tr>
<td>幂等要求</td>
<td>低</td>
<td>高</td>
</tr>
<tr>
<td>权限</td>
<td>读权限</td>
<td>写权限</td>
</tr>
<tr>
<td>是否需要审批</td>
<td>一般不需要</td>
<td>高危必须审批</td>
</tr>
<tr>
<td>失败的影响</td>
<td>信息不准</td>
<td>资金或数据损失</td>
</tr>
<tr>
<td>能不能重试</td>
<td>可以随意重试</td>
<td>必须幂等才能重试</td>
</tr>
<tr>
<td>能不能并行</td>
<td>通常可以</td>
<td>通常不可以</td>
</tr>
<tr>
<td>校验强度</td>
<td>schema 加权限</td>
<td>schema 加权限、业务规则、阈值、幂等</td>
</tr>
</tbody>
</table>
<p>:::warning 两种错误的代价不一样
数据工具的参数写错了，最多是查不到，返回「查不到订单」，重试一次就能收场。</p>
<p>动作工具的参数写错了，可能给错误的订单退了 10000 元，钱已经出去了，后面靠人工补救。
:::</p>
<p>顺着这张表往前推，两边在设计上的待遇也不同。数据工具可以交给模型自由调用，能并行、能缓存、能重试，权限放宽一点问题不大。动作工具要严格 schema、幂等、阈值、审批、审计日志、可回滚，模型的自由度压到最低。</p>
<p>==读放开，写收紧；低危放开，高危收紧。==</p>
<h2>6. 读写分离</h2>
<p>有几类工具既读又写，放在一起最麻烦：</p>
<table>
<thead>
<tr>
<th>工具</th>
<th>读的部分</th>
<th>写的部分</th>
</tr>
</thead>
<tbody>
<tr>
<td>创建工单</td>
<td>查是否已经有同类工单</td>
<td>创建新工单</td>
</tr>
<tr>
<td>改地址</td>
<td>查当前地址</td>
<td>更新地址</td>
</tr>
<tr>
<td>退款</td>
<td>查订单状态和金额</td>
<td>发起退款</td>
</tr>
</tbody>
</table>
<p>处理办法是拆成两个工具，一个只读，一个只写。读的那个宽松，写的那个严格。</p>
<p>::: tabs#refund-tool</p>
<p>@tab 合成一个工具</p>
<pre><code>def refund(order_id: str, amount: float, reason: str):
    order = query_order(order_id)              # 读
    if order["status"] != "paid":
        raise ValueError("订单状态不允许退款")
    return create_refund(order_id, amount)     # 写
</code></pre>
<p>@tab:active 拆成两个工具</p>
<pre><code>def get_refund_eligibility(order_id: str) -&gt; dict:
    """查询订单是否可以退款，不改变任何状态。"""
    ...

def create_refund(order_id: str, amount: float, request_id: str):
    """发起退款。request_id 是幂等键，同一个键重复调用只会退一次。"""
    ...
</code></pre>
<p>:::</p>
<p>拆开之后，权限、审计和审批都能分开配。<code>get_refund_eligibility</code> 谁都能调，<code>create_refund</code> 要写权限，金额超过阈值还要人工确认。合成一个工具的时候，这两套规则没法同时挂上去。</p>
<h2>7. 落到代码</h2>
<h3>7.1 目录按层分</h3>
<p>:::file-tree{title="tools"}</p>
<ul>
<li>tools
<ul>
<li>readonly
<ul>
<li>query_order.py</li>
<li>query_logistics.py</li>
</ul>
</li>
<li>write-low
<ul>
<li>create_ticket.py</li>
<li>notify_user.py</li>
</ul>
</li>
<li>write-high
<ul>
<li>create_refund.py</li>
<li>update_address.py</li>
</ul>
</li>
<li>system
<ul>
<li>run_sql.py
:::</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>目录结构本身就是权限边界。代码评审、上线检查、日志检索都能按层看，新增工具时先想它属于哪个目录。</p>
<h3>7.2 动作工具的参数写成卡片</h3>
<p>参数的描述要写清楚单位、范围和幂等键怎么给：</p>
<p>:::: field-group</p>
<p>::: field order_id
@type string
@required</p>
<p>要退款的订单号。
:::</p>
<p>::: field amount
@type number
@required</p>
<p>退款金额，单位元，最多两位小数。
:::</p>
<p>::: field request_id
@type string
@required</p>
<p>幂等键。同一个 request_id 重复调用只会退一次。
:::</p>
<p>::: field reason
@type string
@optional</p>
<p>退款原因，写进审计日志。
:::</p>
<p>::::</p>
<p>模型的参数错误大多来自描述含糊，而不是能力不够。「金额」两个字和「单位元，最多两位小数」在调用时是两回事。</p>
<h3>7.3 校验和审批挂在调用层</h3>
<pre><code>LAYERS = {
    "readonly": {"approval": False, "retry": 3, "parallel": True},
    "write_low": {"approval": False, "retry": 1, "parallel": False},
    "write_high": {"approval": True, "retry": 0, "parallel": False},
    "system": {"approval": True, "dual_review": True, "retry": 0, "parallel": False},
}

def call_tool(layer, name, args, request_id):
    spec = LAYERS[layer]
    validate(args)                                  # schema 校验
    if spec["approval"] and not approved(layer, name, args):
        raise NeedApproval(layer, name, args)       # 挂起等人确认
    if name in IDEMPOTENT and seen(request_id):
        return cached_result(request_id)            # 幂等命中，返回上次结果
    return dispatch(name, args)
</code></pre>
<p>分层规则集中在一张表里，工具本身不用重复写权限判断。新增工具先选层，规则自动生效。</p>
<p>:::important 权限不写在工具描述里
工具描述是给模型看的，模型能看懂不等于它会遵守。谁可以调、金额上限多少、要不要审批，这些判断放在调用层。相关的讨论在<a href="/posts/agent-design-patterns-supplement/">第四篇</a>里。
:::</p>
<h2>8. 走一遍退款</h2>
<p>用户说「我要退款，订单 12345」，整条路径是这样的：</p>
<pre><code>flowchart TD
  A[用户：我要退款，订单 12345] --&gt; B[查订单，只读]
  B --&gt; C[查物流，只读]
  C --&gt; D[查退款政策，只读]
  D --&gt; E{满足退款条件?}
  E -- 否 --&gt; F[回复不满足并说明原因]
  E -- 是 --&gt; G{金额大于 1000?}
  G -- 否 --&gt; H[发起退款，幂等]
  G -- 是 --&gt; I[人工审批]
  I -- 批准 --&gt; H
  I -- 驳回 --&gt; F
  H --&gt; J[写审计日志]
  J --&gt; K[通知用户]
</code></pre>
<p>每一步的分层待遇：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>层</th>
<th>重试</th>
<th>审批</th>
</tr>
</thead>
<tbody>
<tr>
<td>查订单、查物流、查政策</td>
<td>只读</td>
<td>允许，最多三次</td>
<td>不需要</td>
</tr>
<tr>
<td>发起退款</td>
<td>高危写</td>
<td>不重试，靠幂等键兜底</td>
<td>金额超阈值要人工确认</td>
</tr>
<tr>
<td>写审计日志</td>
<td>低危写</td>
<td>允许</td>
<td>不需要</td>
</tr>
<tr>
<td>通知用户</td>
<td>低危写</td>
<td>允许</td>
<td>不需要</td>
</tr>
</tbody>
</table>
<p>前面三步都是只读，可以并行，查错了没有后果。从「发起退款」开始收紧：这次调用会真的把钱转出去，所以它没有重试次数，靠幂等键防重复，超过阈值就停下来等人。</p>
<h2>9. 新手常踩的四个坑</h2>
<ol>
<li><strong>把读和写塞进同一个工具。</strong> 名字叫 <code>manage_order</code>，里面既能查又能改，权限和审批没法配。拆成 <code>get_order</code> 和 <code>update_order</code> 两个。</li>
<li><strong>把业务规则写进工具描述。</strong> 「退款金额不要超过 1000」写在描述里，模型未必照做。阈值判断放调用层。</li>
<li><strong>动作工具没有幂等键。</strong> 网络超时之后重试，同一个订单可能退两次款。参数里带上 <code>request_id</code>，服务端按它去重。</li>
<li><strong>小项目也照搬五层。</strong> 如果只是内部工具，没有资金和对外发送，读和写两层就够。分层是为了控制风险，层数超过实际风险就只剩开销。</li>
</ol>
<h2>10. 检查清单</h2>
<ul>
<li>每个工具属于哪一层，写在代码或文档里了吗？</li>
<li>读和写是不是分开的？</li>
<li>动作工具有幂等键吗？</li>
<li>阈值和审批规则挂在调用层，还是写在提示词里？</li>
<li>每层的日志能单独查吗？</li>
<li>新增一个工具，要改几处代码？</li>
</ul>
<p>:::note 相关文章
这篇是新手向的工具部分，和 20 种 Agent 设计模式系列互补：</p>
<ul>
<li><a href="/posts/agent-design-patterns-control-flow/">（一）控制流</a></li>
<li><a href="/posts/agent-design-patterns-multi-agent-memory/">（二）多 Agent 与记忆</a></li>
<li><a href="/posts/agent-design-patterns-reliability/">（三）可靠性</a></li>
<li><a href="/posts/agent-design-patterns-supplement/">（四）软约束与硬校验</a>
:::</li>
</ul>
<p>*[SQL]: Structured Query Language</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>20 种 Agent 设计模式（四）：补充设计模式</title>
    <link href="https://cnyicheng.top/posts/agent-design-patterns-supplement/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/agent-design-patterns-supplement/</id>
    <published>2026-09-27T08:00:00.000Z</published>
    <updated>2026-09-27T08:00:00.000Z</updated>
    <summary>把提示词链和规划拆成提示词版和工程版两种实现，看纯提示词的流程为什么拦不住跑题，以及结构校验、目标校验、状态机、权限校验分别挂在哪一层，最后给一个能跑起来的最小编排器。</summary>
    <content type="html"><![CDATA[<p>有人问过我一个问题：提示词链和规划是不是都属于提示词层面的东西，有没有机制能强制校验 Agent 有没有跑题。</p>
<p>这个问题问得准。我把之前一条竞品分析链里的校验点全删掉，只留一段「请按以下三步执行」，结果第二步它就开始写报告，第三步又把第二步换个说法重复了一遍。</p>
<p>写下步骤等于告诉模型该怎么做，不等于它必须这么做。</p>
<p>系列前三篇分别写了控制流、多 Agent 协作和可靠性。这一篇补的是它们底下那层东西：软约束和硬校验各自长在哪里，谁能真的拦住跑题。</p>
<h2>1. 一条没有校验的链</h2>
<p>那条链的任务是写竞品分析：提取竞品信息 → 分析优劣势 → 写报告。提示词里我明确写了「请按以下三步执行，每步单独输出」。跑出来的过程大概是这样：</p>
<pre><code>第 1 步：已收集到 A、B、C 三家竞品的信息。

第 2 步：A 家最近在做行业整合，整个赛道从 2024 年以来……
（后面跟了八百多字行业历史）

第 3 步：综上所述，A 家在这轮行业整合中的位置如下……
</code></pre>
<p>第二步写成了行业综述，第三步把第二步的结论换个说法抄了一遍。整段输出读起来完整，没有报错，没有任何地方提醒我它偏了。</p>
<p>提示词给出的是顺序，不是约束。下面把两种实现分开看。</p>
<h2>2. 提示词链的两种实现</h2>
<p>提示词链有两种写法，差别不在步骤本身，而在谁控制步骤之间的流转。</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>提示词版</th>
<th>工程版</th>
</tr>
</thead>
<tbody>
<tr>
<td>步骤写在哪</td>
<td>同一条提示词里</td>
<td>代码里的调用顺序</td>
</tr>
<tr>
<td>谁决定下一步的输入</td>
<td>模型自己挑</td>
<td>上一个函数的返回值</td>
</tr>
<tr>
<td>能不能跳步</td>
<td>能</td>
<td>不能，没有那个入口</td>
</tr>
<tr>
<td>跳步能不能被发现</td>
<td>看不出来</td>
<td>返回值对不上 schema 就暴露了</td>
</tr>
<tr>
<td>出错怎么定位</td>
<td>只能整段重跑</td>
<td>定位到具体某一步</td>
</tr>
<tr>
<td>改一步的成本</td>
<td>改提示词，牵动其他步骤</td>
<td>改那个函数</td>
</tr>
</tbody>
</table>
<p>提示词版长这样：</p>
<pre><code>prompt = """请按以下步骤完成任务：
1. 提取需求
2. 根据需求生成大纲
3. 根据大纲写正文
每一步单独输出，不要跳步。"""
answer = llm(prompt, user_input)
</code></pre>
<p>这段提示词给了模型一个顺序。它可以照做，能合并两步、跳过一步，或者在第二步顺手把第三步一起做完。你拿到的是最终那段文字，从里面分辨不出它是怎么走到这里的。</p>
<p>工程版长这样：</p>
<pre><code>outline = llm("根据主题列一份提纲", topic)
draft = llm("按照提纲写初稿", outline)
final = llm("润色这段文字", draft)
</code></pre>
<p>第三步的输入就是第二步的返回值，模型没有跳过第二步的入口。它能自由发挥的范围被压到单次调用内部。</p>
<pre><code>flowchart TB
  subgraph P[提示词版]
    A1[一条提示词] --&gt; A2[模型自己排步骤]
    A2 --&gt; A3[一段输出]
  end
  subgraph E[工程版]
    B1[步骤一] --&gt; B2[步骤二]
    B2 --&gt; B3[步骤三]
  end
</code></pre>
<p>工程版也不是万能的。它管住的是步骤之间的边界，单步内部的质量还是空的。第三步的提示词要求「只写三个自然段」，模型写了五个，调用顺序看不出来。这类问题要等校验层来接，见第 5 节。</p>
<h2>3. 计划放哪儿</h2>
<p>规划模式有两套实现，区别在计划存在哪里。</p>
<pre><code>resp = llm("先列出完成这个任务的步骤，然后按步骤执行。", task)
</code></pre>
<p>这里的计划是模型输出里的一段文字，躺在对话上下文里。后面几轮推理把它挤掉一部分，模型就可能自己长出新的步骤。执行到一半时，你也没法问它「第二步做完了吗」。</p>
<pre><code>{
  "goal": "写一份竞品分析报告",
  "steps": [
    { "id": 1, "task": "收集竞品信息", "status": "done" },
    { "id": 2, "task": "分析优劣势", "status": "in_progress" },
    { "id": 3, "task": "撰写报告", "status": "pending" }
  ]
}
</code></pre>
<p>计划变成了一份数据结构。能改它的是代码，模型提交的是某一步的结果，而不是「我觉得现在该做第四步了」。</p>
<table>
<thead>
<tr>
<th></th>
<th>提示词里的计划</th>
<th>系统里的计划</th>
</tr>
</thead>
<tbody>
<tr>
<td>存在哪</td>
<td>对话上下文</td>
<td>结构化状态</td>
</tr>
<tr>
<td>谁能改</td>
<td>模型</td>
<td>代码</td>
</tr>
<tr>
<td>进度能不能查</td>
<td>不能</td>
<td>读 status 就行</td>
</tr>
<tr>
<td>失败之后怎么办</td>
<td>让模型重写一段计划</td>
<td>把这一步重置成 pending 再跑</td>
</tr>
<tr>
<td>重启之后还在吗</td>
<td>不在</td>
<td>在，只要状态落了盘</td>
</tr>
</tbody>
</table>
<h2>4. 软约束会怎么失效</h2>
<p>把上面那条链的翻车方式列出来，一共六种：</p>
<table>
<thead>
<tr>
<th>失效方式</th>
<th>具体表现</th>
<th>纯提示词能不能发现</th>
</tr>
</thead>
<tbody>
<tr>
<td>跳步</td>
<td>从需求直接跳到正文</td>
<td>不能</td>
</tr>
<tr>
<td>合并步骤</td>
<td>大纲和正文挤在一段里</td>
<td>不能</td>
</tr>
<tr>
<td>插入无关内容</td>
<td>竞品分析里写了半页行业历史</td>
<td>不能</td>
</tr>
<tr>
<td>忘记目标</td>
<td>报告越写越像市场调研</td>
<td>不能</td>
</tr>
<tr>
<td>自造步骤</td>
<td>多出「总结行业趋势」这一步</td>
<td>不能</td>
</tr>
<tr>
<td>格式跑偏</td>
<td>该出 JSON 的地方出了一段散文</td>
<td>能，前提是你写了校验</td>
</tr>
</tbody>
</table>
<p>前五种都要读输出才能判断，而读输出这件事如果没有外部标准，最后还是模型自己说了算。第六种有确定的判据，所以能写成代码拦住。</p>
<h2>5. 四种硬校验</h2>
<p>:::note 判据从哪来
每一层校验都要有一个不依赖模型的判据：schema、状态约束、权限表、人的决定。判据是模型自己给的，校验就退化回软约束。
:::</p>
<h3>5.1 结构校验</h3>
<pre><code>def call_with_schema(prompt, payload, schema, retries=2):
    for _ in range(retries + 1):
        raw = llm(prompt, payload)
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            continue
        if validate(data, schema):
            return data
    raise SchemaError("连续三次都没有给出合规输出")
</code></pre>
<p>判据是确定的：字段有没有漏、类型对不对、枚举值在不在范围内。代价是重试，最坏情况多两次调用。</p>
<h3>5.2 目标校验</h3>
<pre><code>ON_TOPIC = """原始目标：{goal}

这一步的输出：{output}

判断这段输出是否仍然服务于原始目标。
只返回 JSON：{{"on_topic": true, "reason": "偏离原因"}}"""

def check_on_topic(goal, output):
    return json.loads(llm(ON_TOPIC.format(goal=goal, output=output), ""))
</code></pre>
<p>判定交给另一个模型，判据是自然语言写的一句话。这里多出一个新的不确定性：判定器自己会不会看走眼。上线之前先攒一批人工标过的输出当黄金集，量出它的准确率，再让它拦主流程。</p>
<h3>5.3 状态机校验</h3>
<pre><code>def run_plan(plan, step_impl):
    while True:
        running = [s for s in plan["steps"] if s["status"] == "in_progress"]
        if running:
            raise PlanViolation("上一步还没收尾")
        step = next((s for s in plan["steps"] if s["status"] == "pending"), None)
        if step is None:
            return plan
        step["status"] = "in_progress"
        try:
            step["result"] = step_impl[step["id"]](plan)
            step["status"] = "done"
        except Exception as exc:
            step["status"] = "failed"
            step["error"] = str(exc)
</code></pre>
<p>循环只从 pending 里取步骤，取之前先确认没有正在跑的步骤。跳步在这里没有实现路径：第三步想跑，它得先变成 pending，而只有代码能改 status，模型交回来的只是一段结果。</p>
<h3>5.4 权限与审批点</h3>
<p>这三类检查放在调用层，不放在提示词里：</p>
<ul>
<li>工具白名单：模型请求调用列表外的工具，直接拦下。提示词里写「不要调用删除接口」，挡不住它。</li>
<li>参数范围：退款金额超过阈值就转人工，规则写在代码里。</li>
<li>审批断点：高风险步骤停下来等人确认，确认结果写进状态，进程重启之后不会丢。</li>
</ul>
<p>:::warning 提示词不是权限
在提示词里写「不要做某件事」，等于把这条规则的执行权交给了被约束的那一方。权限类规则要在工具调用层拦。
:::</p>
<h2>6. 四层结构</h2>
<pre><code>flowchart TD
  A[提示词层：告诉模型做什么] --&gt; B[编排层：决定调谁、传什么]
  B --&gt; C[校验层：判输出合不合规、还在不在题上]
  C -- 不合规 --&gt; B
  C -- 合规 --&gt; D[人工层：高风险步骤停下来等人]
  D -- 驳回 --&gt; B
  D -- 批准 --&gt; E[交付]
</code></pre>
<table>
<thead>
<tr>
<th>层</th>
<th>硬还是软</th>
<th>靠什么生效</th>
<th>失效的时候</th>
</tr>
</thead>
<tbody>
<tr>
<td>提示词层</td>
<td>软</td>
<td>模型的遵循程度</td>
<td>跑题了也没人知道</td>
</tr>
<tr>
<td>编排层</td>
<td>半硬</td>
<td>代码里的调用顺序</td>
<td>顺序能保证，单步内部的质量保证不了</td>
</tr>
<tr>
<td>校验层</td>
<td>硬</td>
<td>schema、规则、判定器</td>
<td>不合规就打回重做</td>
</tr>
<tr>
<td>人工层</td>
<td>硬</td>
<td>审批动作</td>
<td>没批准进不了下一步</td>
</tr>
</tbody>
</table>
<p>:::important 编排层只是半硬
它管得住步骤之间的边界。第三步的提示词要求只写三点，模型写了七点，状态机看不出来。这种情况要交给校验层的结构校验，或者把这一步的输出也定义成 schema。
:::</p>
<h2>7. 最小骨架</h2>
<p>把四种校验拼成一个循环，大概四十行：</p>
<pre><code>def run_pipeline(goal, plan, steps, next_input, approver=None):
    while True:
        step = next((s for s in plan["steps"] if s["status"] == "pending"), None)
        if step is None:
            return plan

        if approver and step["id"] in approver["ids"]:
            if approver["ask"](goal, step) != "approve":
                step["status"] = "rejected"
                return plan

        step["status"] = "in_progress"
        spec = steps[step["id"]]
        data = call_with_schema(spec["prompt"], next_input, spec["schema"])

        verdict = check_on_topic(goal, data)
        if not verdict["on_topic"]:
            step["status"] = "off_topic"
            step["reason"] = verdict["reason"]
            return plan

        next_input = data
        step["status"] = "done"
</code></pre>
<p>循环停下来的时候只有四种状态：done 是走完了，failed 是调用失败，off_topic 是判定跑题，rejected 是人工驳回。任何一种停下来，调用方都能从 plan 里读到停在哪一步、为什么停。这段代码做的事情，是把「跑题」从一个读输出才能察觉的现象，变成一条带原因的状态记录。</p>
<h2>8. 加在哪一层</h2>
<p>每一层都有成本。结构校验的重试和目标校验的判定器都是额外的模型调用，延迟和 token 直接跟着涨；判定器要维护黄金集，规则要跟着业务改。</p>
<table>
<thead>
<tr>
<th>任务类型</th>
<th>建议到哪一层</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr>
<td>内部草稿、日志摘要</td>
<td>结构校验</td>
<td>出错了没人看见，重跑一遍就行</td>
</tr>
<tr>
<td>对外内容、客服回复</td>
<td>结构校验 + 目标校验 + 状态机</td>
<td>跑题会被用户直接看到</td>
</tr>
<tr>
<td>花钱、发信、改数据</td>
<td>上面三层 + 权限校验与审批断点</td>
<td>失败的代价不可逆</td>
</tr>
</tbody>
</table>
<p>顺序上我建议从 schema 开始，再加状态机，然后是目标校验，最后才是审批断点。反着做，前期投入会花在还用不到的地方。</p>
<h2>9. 脉络表</h2>
<table>
<thead>
<tr>
<th>要回答的问题</th>
<th>靠什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>该按什么顺序做</td>
<td>提示词链、规划</td>
</tr>
<tr>
<td>这一步的输出合不合格</td>
<td>结构校验</td>
</tr>
<tr>
<td>这一步还在不在题上</td>
<td>目标校验</td>
</tr>
<tr>
<td>能不能进下一步</td>
<td>状态机</td>
</tr>
<tr>
<td>能不能用这个工具</td>
<td>权限校验</td>
</tr>
<tr>
<td>出错之后怎么办</td>
<td>异常处理与恢复</td>
</tr>
<tr>
<td>该不该让人看一眼</td>
<td>人类在环</td>
</tr>
</tbody>
</table>
<p>配合前三条看会更顺：<a href="/posts/agent-design-patterns-control-flow/">控制流</a>、<a href="/posts/agent-design-patterns-multi-agent-memory/">多 Agent 与记忆</a>、<a href="/posts/agent-design-patterns-reliability/">可靠性</a>。这一篇补的是它们共用的那套校验骨架。</p>
<p>下一步可以玩什么：</p>
<ul>
<li>给现有的链加一个 schema，把跳步变成能检测的失败，而不是等到读输出才发现。</li>
<li>把计划从提示词搬进 JSON，跑一遍，看模型卡在哪一步。</li>
<li>给判定器攒三十条人工标过的样本，先量准确率再上线。</li>
<li>挑一个高风险步骤做审批断点，把人驳回的原因记下来看分布。</li>
</ul>
<p>:::note 系列说明
本文选题与 20 种模式分类拓展自 lvy 的博客《20种Agent 设计模式》：
<a href="https://blog.csdn.net/2301_80171004/article/details/160947992">原文链接</a></p>
<p>原文图片没有直接搬运，系列里的结构图均按文章需要重新绘制。
:::</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>20 种 Agent 设计模式（三）：上线前质量门，上线后监控、护栏与恢复</title>
    <link href="https://cnyicheng.top/posts/agent-design-patterns-reliability/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/agent-design-patterns-reliability/</id>
    <published>2026-09-26T09:26:00.000Z</published>
    <updated>2026-09-26T09:26:00.000Z</updated>
    <summary>最后一篇拆质量保障、评估监控、护栏、人类在环和异常恢复：Agent 上线后到底靠谁判断、怎么防漂移、为什么反思不能自己评自己。</summary>
    <content type="html"><![CDATA[<p>评估与监控这一节，我看到一个很实际的问题：</p>
<blockquote>
<p>部署前的内容我可以让人工审核，部署后这些判断靠什么？定期脚本？外挂 Agent？还是人工？</p>
</blockquote>
<p>这个问题刚好把质量保障、人类在环、异常恢复和学习适应几个模式串到一起。Agent 上线之后，真正的难点从“能不能回答”变成了“怎么知道它回答得对”。</p>
<h2>1. 上线前：质量门和黄金测试</h2>
<p>质量门是上线必须满足的条件。比如准确率不低于 95%，核心路径响应时间不超过 200ms，高风险工具调用必须经过权限校验。</p>
<p>质量门需要一套测试来支撑：</p>
<table>
<thead>
<tr>
<th>测试类型</th>
<th>检查什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>单元测试</td>
<td>解析、清洗、参数拼装这类局部逻辑</td>
</tr>
<tr>
<td>契约测试</td>
<td>工具接口、模型输出格式、上下游约定</td>
</tr>
<tr>
<td>集成测试</td>
<td>多个模块组合后的行为</td>
</tr>
<tr>
<td>关键路径测试</td>
<td>下单、审批、工单关闭这类核心流程</td>
</tr>
</tbody>
</table>
<p>黄金集是质量门里最值得先投入的部分。找 20 到 50 条真实用例，覆盖核心场景和典型失败场景。没有黄金集，后面的模型升级和提示词改动都会变成凭感觉。</p>
<pre><code>GOLDEN_CASES = [
    {"input": "...", "expected_tool": "query_order", "must_contain": ["订单号"]},
    {"input": "...", "expected_tool": "refund", "risk": "high"},
]

def quality_gate(run_agent) -&gt; bool:
    passed = 0
    for case in GOLDEN_CASES:
        result = run_agent(case["input"])
        if check_case(result, case):
            passed += 1
    return passed / len(GOLDEN_CASES) &gt;= 0.95
</code></pre>
<p>这段代码里 <code>check_case</code> 可以先从规则和人工标注开始，后面再逐步换成更细的评估器。</p>
<h2>2. 上线后：四类监控</h2>
<p>上线后的监控通常看四个维度：</p>
<ul>
<li>准确性：答案是否正确，工具选择是否合理。</li>
<li>性能：延迟、吞吐量、错误率。</li>
<li>成本：Token 消耗、单次调用成本。</li>
<li>漂移：数据分布和模型行为是否偏离预期。</li>
</ul>
<p>判断方式不是单点，而是三层配合：</p>
<table>
<thead>
<tr>
<th>层级</th>
<th>负责什么</th>
<th>例子</th>
</tr>
</thead>
<tbody>
<tr>
<td>脚本</td>
<td>可确定性判断</td>
<td>格式校验、接口错误、超时、Token 统计</td>
</tr>
<tr>
<td>评估 Agent</td>
<td>模糊质量判断</td>
<td>相关性评分、事实一致性、回答结构</td>
</tr>
<tr>
<td>人工</td>
<td>高风险和边缘情况</td>
<td>退款审批、医疗建议、合同条款</td>
</tr>
</tbody>
</table>
<p>脚本适合处理能被代码判断的指标。评估 Agent 适合处理需要语义判断的样本，但评估器本身也要定期校准。人工只处理高风险和低置信度样本，不能把所有人都拉进审核队列，否则告警疲劳很快就会来。</p>
<pre><code>def evaluate(trace):
    if trace.error:
        return {"status": "fail", "reason": "tool_error"}

    if not trace.output_schema_valid:
        return {"status": "fail", "reason": "schema_error"}

    score = llm_judge(trace.input, trace.output, trace.evidence)
    if score &lt; 0.6:
        return {"status": "review", "reason": "low_score"}

    return {"status": "pass", "score": score}
</code></pre>
<p>评估逻辑要先处理确定性错误，再进入语义判断。这样能避免让模型评估一堆本来就格式错误的结果。</p>
<h2>3. 漂移与回归</h2>
<p>模型漂移指的是同一个模型、相似的输入，输出质量随着时间慢慢变差或者变得不可预测。推荐系统里用户兴趣变了，风控系统里欺诈模式变了，都可能引发漂移。</p>
<p>回归是另一种信号：原本稳定的指标突然偏离。比如准确率长期在 95% 左右，某天掉到 80%，并且持续不回升。</p>
<p>处理漂移和回归时，有三个动作比较实用：</p>
<ol>
<li>保留每次运行的输入特征、工具调用和输出摘要。</li>
<li>按周或者按版本对比核心指标，监控均值和方差。</li>
<li>阈值触发后先归因，不要直接把问题推给模型。</li>
</ol>
<p>漂移可能来自数据变化、模型版本变化、工具接口变化，还可能来自提示词或上下文组装逻辑。只看最终准确率，很难判断该改哪一层。</p>
<h2>4. 反思模式不能自己评自己</h2>
<p>反思模式让模型先生成初稿，再批评和修正。这个模式在写作、代码修复、方案优化里都很有用。</p>
<p>但它有一个前提：批评必须有外部信号。</p>
<p>如果只让模型自己评价自己，它很容易围绕同一套叙事反复修改，甚至把正确内容改错。工程上要加三类约束：</p>
<ul>
<li>外部标准：测试用例、检索证据、规则校验、评分器。</li>
<li>变更记录：保存每一轮的 diff，知道改了什么。</li>
<li>最大轮次：通常 2 到 3 轮就够，超过后收益会快速下降。</li>
</ul>
<pre><code>def refine_with_reflection(task, max_rounds=3):
    output = generate(task)
    for _ in range(max_rounds):
        issues = critique(output, task)
        if not issues:
            break
        output = revise(output, issues)
        if not external_check(output, task):
            output = rollback()
            break
    return output
</code></pre>
<p>其中 <code>external_check</code> 是关键。它可以是单测，可以是检索到的证据，或者一套评分标准。没有它，反思只是在同一层里绕圈。</p>
<h2>5. 护栏分四层</h2>
<p>护栏与安全模式容易被理解成“入口过滤敏感词”。真实系统里，护栏至少要覆盖四层：</p>
<pre><code>flowchart LR
  A[输入] --&gt; B[输入护栏]
  B --&gt; C[模型输出]
  C --&gt; D[工具调用护栏]
  D --&gt; E[工具执行]
  E --&gt; F[最终输出护栏]
  F --&gt; G[返回用户]
</code></pre>
<ul>
<li>输入护栏处理提示注入、恶意内容和隐私信息。</li>
<li>模型输出护栏处理政策、合规和品牌安全。</li>
<li>工具调用护栏检查权限、参数范围和风险等级。</li>
<li>最终输出护栏检查交付内容是否越权、是否泄露内部信息。</li>
</ul>
<p>只做输入过滤挡不住模型生成阶段的风险，还挡不住工具参数越权。高风险动作需要审批和沙箱，不能只靠提示词约束。</p>
<h2>6. 人类在环</h2>
<p>人类在环不是“系统做不了就找人”。它应该是一个有触发条件、有队列、有超时的流程。</p>
<p>常见触发条件：</p>
<ul>
<li>风险等级高，例如退款、删除数据、对外发送邮件。</li>
<li>置信度低，模型无法确认下一步。</li>
<li>金额、权限或合规阈值被越过。</li>
<li>评估器给出低分，或者多个评估器结论冲突。</li>
</ul>
<p>人工介入时，界面要展示完整上下文、变更 diff 和可执行动作。批准、拒绝、接管、补充信息这些操作都要有明确结果，做完之后再把控制权交还给系统。</p>
<p>人类在环会增加延迟，所以不能所有请求都走人工。把明确规则交给脚本和评估 Agent，把真正需要判断的部分留给人。</p>
<h2>7. 异常处理与恢复</h2>
<p>异常处理模式可以按错误类型分三条路：</p>
<ul>
<li>永久性错误：重试没有意义，走备用方案或人工处理。</li>
<li>临时性错误：指数退避重试，限制最大次数。</li>
<li>关键响应错误：保存现场、通知团队、必要时中止流程。</li>
</ul>
<pre><code>def call_with_recovery(action):
    for attempt in range(3):
        try:
            return action()
        except TemporaryError:
            sleep(2 ** attempt)
        except PermanentError:
            return fallback()
    save_checkpoint()
    return escalate_to_human()
</code></pre>
<p>恢复逻辑要和状态保存配合。长任务必须把已完成步骤、当前输入和失败位置写下来，否则重试只能从头开始。</p>
<h2>8. 学习与适应</h2>
<p>学习与适应模式收集用户修正、评分和自动化评估结果，用来更新提示词、策略或者工具配置。微调模型通常是最后选项，不是第一反应。</p>
<p>反馈数据要先清洗和验证，防止把恶意反馈或者错误标注学进去。更新之后还要做 A/B 测试，确认指标真的变好，再逐步放大。</p>
<pre><code>def learn_from_feedback(case, feedback):
    if not validate_feedback(feedback):
        return
    if feedback["type"] == "prompt":
        update_prompt_examples(case, feedback["correction"])
    elif feedback["type"] == "tool":
        update_tool_description(case, feedback["correction"])
</code></pre>
<p>学习闭环不适合追求自动更新一切。提示词示例、工具描述和评估集比模型权重更容易控制，改动成本也更低。</p>
<h2>9. 目标与监控</h2>
<p>目标监控模式要求把目标写成可衡量的形式，再在执行过程中持续检查。</p>
<p>一个可用的目标需要包含四个部分：</p>
<table>
<thead>
<tr>
<th>部分</th>
<th>例子</th>
</tr>
</thead>
<tbody>
<tr>
<td>目标</td>
<td>把退款工单平均处理时间降到 5 分钟</td>
</tr>
<tr>
<td>约束</td>
<td>高风险退款必须人工确认</td>
</tr>
<tr>
<td>指标</td>
<td>处理时间、人工介入率、错误率</td>
</tr>
<tr>
<td>检查点</td>
<td>每 100 单检查一次</td>
</tr>
</tbody>
</table>
<p>目标监控的难点是冲突。处理时间下降可能带来错误率上升，人工介入减少可能带来风险增加。监控多个指标时，要提前定义谁优先，不能等冲突出现再临时决定。</p>
<h2>10. 可靠性闭环</h2>
<p>把前面这些串起来，是一个完整的闭环：</p>
<pre><code>flowchart LR
  A[黄金集与质量门] --&gt; B[上线]
  B --&gt; C[运行监控]
  C --&gt; D{异常检测}
  D -- 正常 --&gt; E[定期审计]
  D -- 异常 --&gt; F[归因分析]
  F --&gt; G[修复或回滚]
  G --&gt; H[更新评估集]
  H --&gt; A
  E --&gt; H
</code></pre>
<p>这个闭环里，评估集不是一次性的验收文档。每次线上事故和边缘情况都应该回流到评估集，让下一轮上线质量门更接近真实问题。</p>
<p>:::note 系列说明
本文选题与 20 种模式分类拓展自 lvy 的博客《20种Agent 设计模式》：
<a href="https://blog.csdn.net/2301_80171004/article/details/160947992">原文链接</a></p>
<p>原文图片没有直接搬运，系列里的结构图均按文章需要重新绘制。
:::</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>20 种 Agent 设计模式（二）：多 Agent 不是聊天室，协调者、共享记忆，多Agent协作谁等谁</title>
    <link href="https://cnyicheng.top/posts/agent-design-patterns-multi-agent-memory/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/agent-design-patterns-multi-agent-memory/</id>
    <published>2026-09-26T09:25:00.000Z</published>
    <updated>2026-09-26T09:25:00.000Z</updated>
    <summary>继续拆 20 种 Agent 设计模式：中央协调者、共享记忆、依赖图、契约验证和 Swarm 的 Context Variables。重点回答 Agent 之间是否互相知道、数据怎么流动、谁等谁。</summary>
    <content type="html"><![CDATA[<p>有个问题我后来被问过好几次：</p>
<blockquote>
<p>多个 Agent 在一起工作时，它们知道彼此存在吗？会像聊天室一样互相发消息吗？</p>
</blockquote>
<p>这个问题正好对应 20 种模式里的多智能体协作、共享记忆、规划、工具使用和智能体间通信。它也是多 Agent 系统最容易被讲乱的部分。</p>
<p>这篇先把 Agent 之间的真实关系讲清楚，再往实现层拆。</p>
<h2>1. 三种关系：隔离、黑板、直接通信</h2>
<p>多 Agent 协作不等于多个 Agent 开群聊。落地时有三种常见关系：</p>
<table>
<thead>
<tr>
<th>关系</th>
<th>Agent 是否知道彼此</th>
<th>数据怎么流动</th>
<th>风险</th>
</tr>
</thead>
<tbody>
<tr>
<td>完全隔离</td>
<td>不知道</td>
<td>协调者把输入写进工单，Agent 只认工单</td>
<td>协调者成为单点，工单设计要足够清楚，风险和微服务之前的Java业务一样</td>
</tr>
<tr>
<td>共享黑板</td>
<td>不直接知道，但能读同一块记忆</td>
<td>上游写进共享记忆，下游按 key 读取</td>
<td>命名空间冲突、脏读、记忆无限膨胀</td>
</tr>
<tr>
<td>直接通信</td>
<td>知道，能互相发消息</td>
<td>Agent 之间直接传递消息</td>
<td>对话轮次爆炸、死循环、责任边界模糊</td>
</tr>
</tbody>
</table>
<p>大多数企业项目落在前两种。中央协调者加流水线的结构通常属于第二种，Agent 之间不直接对话，数据通过协调者或共享记忆中转。</p>
<h2>2. 中央协调者、工单和契约</h2>
<p>把第二种结构画出来，大概是这张图：</p>
<pre><code>flowchart LR
  U[用户 Query] --&gt; C[中央协调者]
  C --&gt; M[共享记忆]
  C --&gt; A1[Agent 1]
  C --&gt; A2[Agent 2]
  C --&gt; A3[Agent 3]
  A1 --&gt; T1[MCP / 搜索 / 数据库]
  A2 --&gt; T2[MCP / 搜索 / 数据库]
  A3 --&gt; T3[MCP / 搜索 / 数据库]
  A1 --&gt; C
  A2 --&gt; C
  A3 --&gt; C
  C --&gt; O[最终输出]
</code></pre>
<p>协调者做的事情可以拆成四步：</p>
<ol>
<li>拆解任务，生成依赖关系图。</li>
<li>按依赖顺序派发工单。</li>
<li>收集 Agent 输出，写进共享记忆。</li>
<li>按验收标准检查，通过就继续，不通过就打回重做。</li>
</ol>
<p>最小实现可以写成这样：</p>
<pre><code>memory = {}
dag = {
    "lighting": {"inputs": []},
    "camera": {"inputs": ["lighting"]},
    "sound": {"inputs": []},
    "editing": {"inputs": ["camera", "sound"]},
}

def dispatch(ticket, memory):
    agent = get_agent(ticket["agent"])
    inputs = {name: memory[name] for name in ticket["inputs"]}
    return agent.run(ticket["goal"], inputs)

def run_plan(dag):
    for node in topological_order(dag):
        ticket = build_ticket(node, dag)
        retries = 0
        while retries &lt; ticket["max_retries"]:
            result = dispatch(ticket, memory)
            memory[f"{node}.output"] = result
            if validate(result, ticket["acceptance_criteria"]):
                break
            retries += 1
        else:
            return replan(node)
    return memory["editing.output"]
</code></pre>
<p>这段代码里真正重要的是几个约束：</p>
<ul>
<li><code>topological_order</code> 保证上游先做。</li>
<li><code>ticket["inputs"]</code> 声明依赖，协调者据此从共享记忆取数据。</li>
<li><code>validate</code> 是契约验证，不达标不放行。</li>
<li><code>max_retries</code> 防止无限循环。</li>
<li><code>replan</code> 是失败后的回溯路径。</li>
</ul>
<p>工单设计得越清楚，Agent 越不需要知道别的 Agent 存在。它只要知道目标、输入和验收标准。</p>
<h2>3. 共享记忆怎么落地</h2>
<p>共享记忆不是所有人随便读写一个大字典。它会出两类问题：两个 Agent 同时写同一个 key，或者下游读到上游没写完的半成品。</p>
<p>常见做法是把存储和权限分开：</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>常见实现</th>
</tr>
</thead>
<tbody>
<tr>
<td>短期记忆</td>
<td>进程内字典、Redis、会话级向量库</td>
</tr>
<tr>
<td>长期记忆</td>
<td>向量数据库、关系库、对象存储</td>
</tr>
<tr>
<td>写入方</td>
<td>协调者独占写，Agent 只返回结果</td>
</tr>
<tr>
<td>读取方</td>
<td>协调者按工单把数据作为输入传入</td>
</tr>
<tr>
<td>防冲突</td>
<td>按任务和 Agent 划命名空间，例如 <code>task-123.camera.output</code></td>
</tr>
</tbody>
</table>
<p>只要写入方只有一个，记忆重叠的概率就会低很多。Agent 在结构上更接近纯函数：输入进去，结果出来，不直接碰共享状态。</p>
<p>这里也能看出 RAG 和记忆管理的分工。RAG 面向外部知识检索，查询的是相对静态的文档库。记忆管理保存用户偏好、任务进展和历史状态，写入更频繁，还要考虑过期和清理。两者可以共用向量数据库，但生命周期和访问模式不同。</p>
<h2>4. 规划任务是统一下发吗，谁等谁</h2>
<p>只要任务有依赖，就会出现等待。</p>
<p>例如灯光数据要先出来，摄影才能开始；摄影和声音可以并行；剪辑必须等摄影和声音都完成。依赖图画出来是这样：</p>
<pre><code>flowchart LR
  L[灯光] --&gt; C[摄影]
  S[声音] --&gt; E[剪辑]
  C --&gt; E
  E --&gt; F[成片]
</code></pre>
<p>协调者通常按依赖图做拓扑排序，算出每一批可以同时执行的节点。同一批里没有依赖的 Agent 可以并行跑，下一批必须等上游完成。</p>
<p>等待方式分两种：</p>
<ul>
<li>同步等待：协调者发出工单后停在原地，等结果回来再继续。</li>
<li>异步等待：协调者把工单挂到任务队列，继续派发其他分支；有依赖的节点通过信号或轮询确认上游完成。</li>
</ul>
<p>同步实现简单，但一条慢链路会拖住整个流程。异步吞吐量高，代价是状态管理和超时处理更复杂。</p>
<p>规划任务并不一定要在一开始全部下发。原文也提到，一种更稳的做法是边做边重规划：每完成一个小阶段就检查状态，必要时调整后面的依赖关系。这比一次生成几十步计划更容易控制。</p>
<h2>5. Swarm 里的数据流</h2>
<p>Swarm 提供了另一种多 Agent 运行时，它和中央协调者模式不完全一样。</p>
<p>在 Swarm 里，Agent 是一份配置，包含指令、模型和函数列表。Swarm 是运行时，负责把消息和工具清单交给模型，执行模型要求的工具调用，处理 Agent 之间的交接。</p>
<p>一次 <code>run()</code> 里，关键数据流是：</p>
<pre><code>result = client.run(
    agent=agent,
    messages=messages,
    context_variables={"user_id": "u-123", "vip": True},
)
</code></pre>
<p><code>context_variables</code> 在一次 run 内共享。工具函数可以读取，instructions 函数也可以读取。它解决的是“同一次运行里，Agent 和函数怎么共享字段”，不负责跨会话保存。</p>
<p>Handoff 的本质是一个特殊工具。模型决定调用它，Swarm 收到返回的目标 Agent，然后更新当前 Agent，下一轮循环继续。</p>
<p>这套机制和我之前写过的 Swarm 入门篇可以连起来看：那一篇讲的是最小骨架，这一篇讲的是骨架里数据怎么流动。</p>
<h2>6. Context Variables 和 LangChain Memory 的区别</h2>
<p>这两个东西名字都带记忆，但负责的范围不同：</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>Swarm Context Variables</th>
<th>LangChain Memory</th>
</tr>
</thead>
<tbody>
<tr>
<td>生命周期</td>
<td>一次 run 内</td>
<td>跨轮次、跨会话</td>
</tr>
<tr>
<td>主要作用</td>
<td>同一次运行里共享字段</td>
<td>保存历史对话和用户状态</td>
</tr>
<tr>
<td>存储位置</td>
<td>调用方传入的字典</td>
<td>外部存储或框架管理的状态</td>
</tr>
<tr>
<td>谁来持久化</td>
<td>调用方</td>
<td>Memory 组件和调用方配合</td>
</tr>
<tr>
<td>典型用途</td>
<td>用户 ID、订单号、权限标记</td>
<td>用户偏好、历史摘要、长期档案</td>
</tr>
</tbody>
</table>
<p>所以“刷新网页后还记得吗”这个问题的答案取决于 Memory，而不是 Context Variables。要跨会话记住用户偏好，需要外部持久化，再加每次运行前注入。</p>
<h2>7. 企业为什么默认单 Agent 多工具</h2>
<p>多 Agent 看起来很热闹，但企业项目通常不会一开始就拆出一堆 Agent。</p>
<p>更常见的选择是单 Agent 加多模型、多工具、多环境。工具池统一接进来，用工具选择和路由优化控制规模。只有当出现下面几类条件时，才考虑拆成多 Agent：</p>
<ul>
<li>安全或合规边界要求隔离。例如交易准备和交易验证必须运行在不同环境。</li>
<li>团队所有权边界不同。不同团队维护不同领域，需要独立开发和部署。</li>
<li>子任务确实可以并行，串行瓶颈已经被验证出来了。</li>
</ul>
<p>如果只是工具很多，优先做工具池和权限管理。如果只是提示词太长，优先做上下文压缩和检索。多 Agent 会带来额外的协调成本、状态同步和故障面。</p>
<h2>8. 什么时候该拆 Agent</h2>
<pre><code>flowchart TD
  A[任务需要多个角色吗?] -- 否 --&gt; B[单 Agent + 工具池]
  A -- 是 --&gt; C{存在安全或团队边界吗?}
  C -- 是 --&gt; D[按边界拆 Agent]
  C -- 否 --&gt; E{子任务能真正并行吗?}
  E -- 是 --&gt; F[拆 Agent 并做依赖图]
  E -- 否 --&gt; G[先保持单 Agent]
  G --&gt; H{提示词或工具已经爆炸?}
  H -- 是 --&gt; I[先做上下文压缩、工具路由]
  H -- 否 --&gt; B
</code></pre>
<p>拆 Agent 之前还要准备三件事：共享状态怎么存、通信协议怎么定、失败怎么回溯。少任何一件，系统都会变得更加难调。</p>
<h2>9. 其余三块：工具、规划和探索</h2>
<p>工具使用模式关心模型怎么发现工具、检查权限、传对参数、处理失败。工具描述和 schema 写不好，模型就会选错工具，后面的流程再漂亮也救不回来。</p>
<p>规划模式关心目标分解、检查和重规划。它和本文第 4 节说的依赖图是同一件事的两面。</p>
<p>探索与发现模式面向开放式研究任务：从论文、数据和专家来源里找模式、聚类主题、判断哪个方向值得继续。它消耗资源多，通常放在明确目标之后，而不是任务一开始就开跑。</p>
<p>:::note 系列说明
本文选题与 20 种模式分类拓展自 lvy 的博客《20种Agent 设计模式》：
<a href="https://blog.csdn.net/2301_80171004/article/details/160947992">原文链接</a></p>
<p>原文图片没有直接搬运，系列里的结构图均按文章需要重新绘制。
:::</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>20 种 Agent 设计模式（一）：提示词链、路由和并行化到底有什么区别</title>
    <link href="https://cnyicheng.top/posts/agent-design-patterns-control-flow/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/agent-design-patterns-control-flow/</id>
    <published>2026-09-26T09:24:00.000Z</published>
    <updated>2026-09-26T09:24:00.000Z</updated>
    <summary>提示词链、路由和并行化是 20 种 Agent 设计模式里最容易混淆的三个。这篇分别讲清楚它们解决的问题、适合的任务、Python 里的落地方式，以及资源感知优化什么时候介入。</summary>
    <content type="html"><![CDATA[<p>常见的 Agent 组织方式我整理成了 20 个模式。</p>
<p>对我来讲提示词链、路由、并行化三个词这三个词分开看都懂，放在一起就很容易混。尤其当我看到一个追问时，脑子里也停了一下：</p>
<blockquote>
<p>并行化不就是把任务分给不同 Agent 吗？那它和路由有什么区别？</p>
</blockquote>
<p>这篇文章先把这三者拆开，再补上资源感知优化。它们都属于控制流模式，解决的问题却不一样。</p>
<h2>1. 先看 20 种模式的分组</h2>
<p>原文把 20 种模式分成了 8 组：</p>
<table>
<thead>
<tr>
<th>分组</th>
<th>模式</th>
</tr>
</thead>
<tbody>
<tr>
<td>流程控制型</td>
<td>提示词链、路由</td>
</tr>
<tr>
<td>效率提升型</td>
<td>并行化、资源感知优化</td>
</tr>
<tr>
<td>质量保障型</td>
<td>反思、评估与监控</td>
</tr>
<tr>
<td>协同管理型</td>
<td>多智能体协作、人类在环</td>
</tr>
<tr>
<td>知识处理型</td>
<td>RAG、记忆管理</td>
</tr>
<tr>
<td>推理决策型</td>
<td>推理技术、目标监控、优先级管理</td>
</tr>
<tr>
<td>错误处理型</td>
<td>异常处理与恢复、学习与适应</td>
</tr>
<tr>
<td>通信与高级模式</td>
<td>智能体间通信、规划、工具使用、探索与发现</td>
</tr>
</tbody>
</table>
<p>这篇文章只覆盖前两组里最核心的四个模式。它们的共同点是决定任务怎么流动，区别在于任务路径是固定的、选出来的、并行的，还是按资源动态调整的。</p>
<h2>2. 提示词链：把固定步骤串起来</h2>
<p>提示词链适合步骤可以提前写死的任务。比如写一篇教程，流程可能是先列提纲，再写初稿，最后润色。每一步都拿上一步的结果当输入，中间加一个验证点。</p>
<p>用 Mermaid 画出来是这样：</p>
<pre><code>flowchart LR
  A[用户输入] --&gt; B[生成提纲]
  B --&gt; C{提纲合格?}
  C -- 否 --&gt; B
  C -- 是 --&gt; D[根据提纲写初稿]
  D --&gt; E{初稿合格?}
  E -- 否 --&gt; D
  E -- 是 --&gt; F[润色成文]
  F --&gt; G[返回结果]
</code></pre>
<p>Python 里大概是这样：</p>
<pre><code>def run_article_chain(topic: str) -&gt; str:
    outline = llm("根据主题列一份提纲", topic)
    if not check_outline(outline):
        outline = retry("重新列提纲", topic)

    draft = llm("按照提纲写初稿", outline)
    if not check_draft(draft):
        draft = retry("根据问题清单修改初稿", draft)

    final = llm("润色这段文字", draft)
    return final
</code></pre>
<p>每一步的验证可以是规则、单测、检索证据，或者一个专门的评估模型。验证点让错误尽早暴露，代价是每加一步就多一次模型调用。</p>
<p>链式流程有三类常见代价：</p>
<ul>
<li>上下文越来越长，后续步骤容易把前面所有内容一起带上，成本和幻觉风险都会增加。</li>
<li>前面步骤出错，后面的步骤会继承这个错误。</li>
<li>每多一个模型推理点，整体延迟就往上加。</li>
</ul>
<p>文档原文给出的经验值是 3 到 5 步。超过这个范围，收益通常会被成本和延迟吃掉。这个数字和我的使用感受基本一致，适合大多数“写报告、做整理、生成规格”这类工作。</p>
<h2>3. 路由：先判断任务属于哪一类</h2>
<p>路由不急着执行任务，它先做分诊。客服系统里有技术、销售、账户三类问题，路由会先判断用户这一句属于哪一类，再交给对应的专家链。</p>
<pre><code>flowchart TD
  A[用户请求] --&gt; B[意图分类]
  B --&gt; C{置信度是否足够?}
  C -- 否 --&gt; D[追问澄清或走兜底路径]
  C -- 是 --&gt; E[路由到对应专家链]
  E --&gt; F[执行并返回结果]
</code></pre>
<p>Python 伪代码：</p>
<pre><code>def route_request(user_input: str) -&gt; str:
    result = classifier(user_input)
    label, confidence = result["label"], result["confidence"]

    if confidence &lt; 0.7:
        return ask_for_clarification(user_input)

    if label == "billing":
        return billing_chain(user_input)
    if label == "tech_support":
        return tech_chain(user_input)

    # 分类器给出未知标签时，不要硬塞进某个专家链
    return human_fallback(user_input)
</code></pre>
<p>路由的核心难点是分错之后的兜底。置信度可以用模型评分，或者写规则判断。两类手段通常要一起用：规则负责处理明确模式，模型负责处理自然语言里的模糊表达。</p>
<p>路由和提示词链的区别在于“路径是否提前固定”。提示词链的步骤是开发者写死的，路由的下一步则取决于输入本身。</p>
<h2>4. 并行化：拆成独立块再同时跑</h2>
<p>并行化适合子任务彼此独立的场景。比如把一份长文档拆成几个章节，同时让多个工作者提取要点，最后统一合并。</p>
<pre><code>flowchart LR
  A[原始任务] --&gt; B[拆分成独立子任务]
  B --&gt; C[工作者 1]
  B --&gt; D[工作者 2]
  B --&gt; E[工作者 3]
  C --&gt; F[统一输出格式]
  D --&gt; F
  E --&gt; F
  F --&gt; G[合并并生成摘要]
</code></pre>
<p>Python 可以用 <code>asyncio.gather</code> 写法：</p>
<pre><code>import asyncio

async def parallel_extract(sections: list[str]) -&gt; list[dict]:
    tasks = [extract_section(section) for section in sections]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    normalized = []
    for item in results:
        if isinstance(item, Exception):
            normalized.append({"error": str(item)})
        else:
            normalized.append(item)
    return normalized
</code></pre>
<p>并行化的关键不只是同时去做一件事，把它分开。它还包含两件事：</p>
<ol>
<li>输出格式要统一，否则合并阶段会写满判断分支。</li>
<li>结果要能溯源，某个工作者失败时要知道失败在哪一段。</li>
</ol>
<p>并行化也会放大复杂度。每个工作者都有调用成本、超时和错误处理。并发数量上去之后，合并逻辑会成为新的故障点，所以一般来讲处理可以分布的且量大的任务可以使用jev+并行/路由+并行。</p>
<h2>5. 三者到底差在哪</h2>
<table>
<thead>
<tr>
<th>维度</th>
<th>提示词链</th>
<th>路由</th>
<th>并行化</th>
</tr>
</thead>
<tbody>
<tr>
<td>执行路径</td>
<td>固定顺序</td>
<td>先选择路径</td>
<td>多个路径同时执行</td>
</tr>
<tr>
<td>核心动作</td>
<td>前一步输出喂给后一步</td>
<td>分类后交给专家</td>
<td>拆分、执行、合并</td>
</tr>
<tr>
<td>适用任务</td>
<td>步骤稳定的流程</td>
<td>多意图入口</td>
<td>子任务彼此独立</td>
</tr>
<tr>
<td>主要风险</td>
<td>错误传播、上下文膨胀</td>
<td>分错类、边缘情况</td>
<td>合并冲突、并发失控</td>
</tr>
<tr>
<td>验证点</td>
<td>每一步都可以验证</td>
<td>验证分类置信度</td>
<td>验证各工作者输出格式</td>
</tr>
<tr>
<td>成本形态</td>
<td>线性增加</td>
<td>一次分类加专家链成本</td>
<td>并发越多成本越集中</td>
</tr>
</tbody>
</table>
<p>选择时可以按顺序问自己三个问题：</p>
<ul>
<li>路径能不能提前写死？能，就用提示词链。</li>
<li>入口是不是有多种意图？是，就用路由。</li>
<li>子任务能不能互不依赖地同时做？能，就用并行化。</li>
</ul>
<p>如果这三个条件同时出现，还可以组合。典型结构是路由先选大方向，专家链内部用提示词链，链里的独立步骤再并行处理。</p>
<h2>6. 资源感知优化：模型也分档</h2>
<p>资源感知优化负责另一件事：同样的任务，用什么档位的模型。</p>
<p>简单分类、字段抽取可以用小模型。复杂规划、代码修复、长文档推理用强模型。中间还有一档标准模型。判断依据可以来自任务类型、输入长度、历史监控数据，或者模型自己给出的复杂度评分。</p>
<pre><code>flowchart LR
  A[任务输入] --&gt; B[复杂度评估]
  B --&gt; C{简单 / 中等 / 复杂}
  C -- 简单 --&gt; D[小模型]
  C -- 中等 --&gt; E[标准模型]
  C -- 复杂 --&gt; F[推理模型]
  D --&gt; G[结果输出]
  E --&gt; G
  F --&gt; G
</code></pre>
<p>监控指标通常看三类：</p>
<ul>
<li>Token 消耗</li>
<li>响应时间</li>
<li>API 成本</li>
</ul>
<p>优化手段里，提示词缓存很实用。系统提示和工具定义这些稳定前缀可以缓存起来，后续请求不用重复发送完整内容。还有一个更朴素的做法：先跑一次快速测试，看任务是否复杂，再决定要不要升级模型。</p>
<p>资源感知优化的风险在于判断标准不好定。判断错了，要么用大模型浪费成本，要么用小模型产出不稳定结果。所以它需要和历史监控数据配合，不能只靠一个拍脑袋的分类器。</p>
<h2>7. 什么时候再上推理技术</h2>
<p>推理技术这一组包括链式思维、树状思维、自我一致性、对抗性辩论等方法。它们适合数学推理、战略规划、法律分析这类复杂问题。</p>
<p>原文给了一个很保守的提醒：90% 的场景不需要上高级推理技术。我认可这个判断。高级推理会增加 Token 消耗，还会把延迟拉高。很多业务问题用提示词链加工具调用就能解决，上树状思维反而会拖慢系统。</p>
<p>选择顺序可以先从简单流程开始。只有当简单流程在评估集上持续失败，且失败原因确实来自推理深度时，再考虑升级。</p>
<h2>8. 选择清单</h2>
<p>把这一篇压缩成一份可以带走的清单：</p>
<ol>
<li>先画任务路径，判断它是否能被预先枚举。</li>
<li>路径固定，优先用提示词链，步骤控制在 3 到 5 步。</li>
<li>入口有多个意图，先做路由，再加兜底和澄清。</li>
<li>子任务互不依赖，用并行化，先统一输出契约。</li>
<li>成本敏感，再加资源感知优化，用监控数据决定模型档位。</li>
<li>简单结构解决不了，再考虑推理技术，不要反过来。</li>
</ol>
<p>:::note 系列说明
本文选题与 20 种模式分类拓展自 lvy 的博客《20种Agent 设计模式》：
<a href="https://blog.csdn.net/2301_80171004/article/details/160947992">原文链接</a></p>
<p>虽然题材和20种设计的结论参考了lvy大佬的文章，但文章内容均由本人学习过程中的感悟总结而提炼出，系列里的结构图均按文章需要重新绘制。
:::</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>接着聊 Swarm：到底谁在干活，以及两种「记忆」是不是一回事</title>
    <link href="https://cnyicheng.top/posts/context-variables-vs-chat-memory/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/context-variables-vs-chat-memory/</id>
    <published>2026-09-20T00:00:00.000Z</published>
    <updated>2026-09-20T00:00:00.000Z</updated>
    <summary>上一篇用 Swarm 讲清了多 Agent 协作的最小骨架，这篇顺着那张时序图再往里看一层：Agent 对象、指令函数、工具函数、模型、框架各自负责什么；再顺着「刷新网页还记得吗」这个追问，把 Context Variables 和 LangChain Memory 的区别讲清楚。</summary>
    <content type="html"><![CDATA[<p>上一篇我们用 Swarm 把多 Agent 协作的最小骨架过了一遍：Agent、Handoff、Context、run() 这几个抽象，以及一次运行里消息是怎么流转的。</p>
<p>写完以后我又回去把那张时序图盯了很久，越看越觉得有个地方不对劲：智能体对象（Agent）在整张图里，好像从头到尾没给谁传过任何东西。</p>
<p>顺着这个疑问往下想，问题就一个个冒出来了：</p>
<ul>
<li>Agent 一直"被获取"，那它自己到底干不干活？</li>
<li>指令函数（instructions）和工具函数（Tool），不都是我自己写的 Python 函数吗，它们是一回事？</li>
<li>决定"转交给哪个 Agent 专家"的，是模型的算力，还是我写死的代码？</li>
<li>用户之前说过的话被共享给了多个 Agent，我关掉网页再刷新进来，它还记得吗？</li>
</ul>
<p>这几个问题问得有点杂，但最后都指向同一件事：在 Swarm 里，到底谁在干活。</p>
<p><img src="./memory-overview.webp" alt="Swarm Context Variables 与 LangChain Memory 的总览对比" /></p>
<p><em>图：两种「记忆」各自负责的部分。</em></p>
<h1>第一部分　先把几个人认清楚</h1>
<p>Swarm 这套东西里其实有五个角色：</p>
<table>
<thead>
<tr>
<th>角色</th>
<th>是什么</th>
<th>在哪</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Agent 对象</strong></td>
<td>配置表：我是谁、我怎么说话、我能用什么工具、我用哪个模型</td>
<td>你的 Python 进程内存里</td>
</tr>
<tr>
<td><strong>instructions 函数</strong></td>
<td>Agent 配置的一部分，用来生成系统提示词</td>
<td>你写的代码里</td>
</tr>
<tr>
<td><strong>Tool / 功能函数</strong></td>
<td>Agent 配置的一部分，真正执行动作（查天气、查账户）</td>
<td>你写的代码里</td>
</tr>
<tr>
<td><strong>AI 模型</strong></td>
<td>推理引擎，算力的来源</td>
<td>人家的服务器上</td>
</tr>
<tr>
<td><strong>Swarm 框架</strong></td>
<td>调度器：读配置、组请求、执行工具、处理交接</td>
<td>你的 Python 进程内存里</td>
</tr>
</tbody>
</table>
<p><img src="./swarm-role-division.webp" alt="Swarm 内部角色分工图" /></p>
<p><em>图：几者之间的数据流。</em></p>
<h2>1. 智能体对象：它不干活，只被查</h2>
<p>可能你会想：智能体对象夹在 Swarm 和指令函数中间，到底是干嘛的？</p>
<p>它什么都不干，只被读取。</p>
<p>把时序图放大看，Agent 只出现在两个动作里：</p>
<pre><code>Swarm框架 → 获取agent.instructions → 智能体对象
          → 获取agent.model        → 智能体对象
</code></pre>
<p>注意箭头方向。是 Swarm 主动去取 Agent 的属性，而不是 Agent 把什么东西推给 Swarm。</p>
<pre><code>agent = Agent(
    name="客服",
    instructions=instructions,   # 字符串 或 函数
    model="gpt-4o",              # 用哪个模型
    functions=[...],             # 挂了哪些工具
)
</code></pre>
<p>对象创建完之后就一直放在那里，等 Swarm 来读。Agent 本身不承担任何计算，作用只是把配置存住。</p>
<p>至于"它全程没传递东西"，这个观察是对的，而且这正是设计意图。Agent 是无状态的配置对象，可以预先创建、反复复用。如果让 Agent 自己往外推数据，它就得维护状态，还得知道当前在跟谁说话、消息发到哪了，那这部分工作就和 Swarm 重叠了。</p>
<p>:::tip 这样设计的好处
因为 Agent 只是一份配置，同一个 Agent 对象可以塞给多次 <code>client.run()</code> 复用。需要不同行为时，另外创建一个 Agent 就行，不用去改框架本身。
:::</p>
<h2>2. 指令函数和工具函数，是两条路</h2>
<p>可能你会想：指令函数能不能当成自己写的 Tool 用？反正都是我写的 Python 函数。</p>
<p>不能。它们都是你写的函数，但走的是两条完全不同的路。</p>
<p><img src="./instructions-vs-tool.webp" alt="instructions 函数与 Tool 的两条路径" /></p>
<p><em>图：两种函数的调用路径。</em></p>
<table>
<thead>
<tr>
<th></th>
<th>instructions 函数</th>
<th>Tool / 功能函数</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>谁调用它</strong></td>
<td>Swarm 框架</td>
<td><strong>AI 模型决定</strong>，Swarm 代执行</td>
</tr>
<tr>
<td><strong>什么时候</strong></td>
<td>每次准备 AI 请求之前</td>
<td>AI 返回 <code>tool_calls</code> 之后</td>
</tr>
<tr>
<td><strong>传什么参数</strong></td>
<td><code>context_variables</code></td>
<td>AI 提取的参数 + <code>context_variables</code></td>
</tr>
<tr>
<td><strong>返回值给谁</strong></td>
<td>返回给 Swarm，拼进系统提示词</td>
<td>作为 tool 消息塞回对话历史</td>
</tr>
<tr>
<td><strong>AI 知道它存在吗</strong></td>
<td><strong>不知道</strong>，只看到它生成后的文字</td>
<td><strong>知道</strong>，看到它的 JSON schema</td>
</tr>
</tbody>
</table>
<p>指令函数由 Swarm 调用，返回的内容会被拼进系统提示词。工具函数由 AI 决定是否调用，Swarm 负责执行，返回的内容作为 tool 消息回到对话历史。</p>
<p>所以指令函数不碰数据库和 API，只负责根据上下文拼一段提示词。真正干活的是工具函数。</p>
<h3>2.1 那"很多段动态 prompt"是怎么选的</h3>
<p>可能你会想：一个 Agent 可以准备很多段动态 prompt，Swarm 怎么确认这次用哪一段？靠 AI 判断吗？</p>
<p>不靠 AI，而且这里没有"从多个 prompt 里挑一个"这个动作。</p>
<p>Swarm 做的事很朴素：</p>
<pre><code>if callable(agent.instructions):
    instructions = agent.instructions(context_variables)  # 是函数 → 调用它
else:
    instructions = agent.instructions                     # 是字符串 → 直接用
</code></pre>
<p><code>callable()</code> 是 Python 内置函数，用来判断一个对象能不能加括号执行。函数返回 <code>True</code>，字符串返回 <code>False</code>。</p>
<p>所谓的"很多段动态 prompt"，其实是同一个函数根据不同的 <code>context_variables</code> 返回不同的字符串：</p>
<pre><code>def instructions(context_variables):
    if context_variables.get("vip"):
        return "你是一位 VIP 专属客服，语气要更热情。"
    return "你是一位普通客服，语气专业即可。"
</code></pre>
<p>这里没有什么智能的成分。哪段提示词被拼出来，完全取决于你写在函数里的判断。Swarm 在准备调用 AI 之前就把这段提示词定好了，之后才连同对话历史、工具 schema 一起发给模型，AI 看到的只是最后那段文字。</p>
<h3>2.2 顺带一个细节：Tool 怎么拿到上下文</h3>
<p>AI 在决定调用工具时，只能从用户说的话里提取参数，比如 <code>location="Paris"</code>。<code>context_variables</code> 不在它的视野里，那是框架自己维护的状态。</p>
<p>Swarm 在中间补了一手：</p>
<pre><code>if __CTX_VARS_NAME__ in func.__code__.co_varnames:
    args[__CTX_VARS_NAME__] = context_variables   # 向功能注入上下文
raw_result = func(**args)                          # 执行功能
</code></pre>
<p>工具函数如果需要上下文，在参数里写上 <code>context_variables</code> 就行，Swarm 会自动传进去。</p>
<h2>3. 模型不是 Swarm 挑的</h2>
<p>可能你会想：Swarm 拿到提示词和工具之后，会判断该用哪个模型吧？</p>
<p>不会。模型名是 Agent 自己配好的，Swarm 只是把它读出来。</p>
<pre><code>agent = Agent(model="gpt-4o", ...)      # 这里就定死了
# Swarm 内部： chat.completions.create(model=agent.model, ...)
</code></pre>
<p>Swarm 把 <code>agent.model</code> 直接填进请求里，中间没有挑选的动作。</p>
<p>模型唯一会变的情况是 handoff：当前 Agent 把对话交给另一个 Agent，Swarm 更新 <code>active_agent</code>，下一轮循环重新去读新 Agent 的 <code>agent.model</code>。变的原因只是换了 Agent。</p>
<h3>3.1 "没有算力，Agent 靠什么告诉 Swarm"</h3>
<p>Agent 不需要算力判断调用吗？没有算力，它怎么告诉 Swarm 该干嘛，难道靠程序员写的逻辑？</p>
<p>答案是，这两件事要分开看。</p>
<p>程序员写的逻辑负责的是"声明"：Agent 叫什么、用哪个模型、挂哪些函数、instructions 是字符串还是函数。这些写代码的时候就定好了，不需要算力。</p>
<p>真正需要算力的部分，是判断用户这句话该不该触发某个函数调用。是调退款函数还是天气函数，还是什么都不调直接回复，这些是模型算出来的。</p>
<table>
<thead>
<tr>
<th></th>
<th>谁负责</th>
<th>需要算力吗</th>
</tr>
</thead>
<tbody>
<tr>
<td>有哪些 Agent、有哪些工具、用哪个模型</td>
<td>程序员（声明）</td>
<td>不需要</td>
</tr>
<tr>
<td>这次该不该调工具、调哪一个</td>
<td>AI 模型</td>
<td>需要</td>
</tr>
<tr>
<td>调完之后怎么执行、状态怎么更新</td>
<td>Swarm</td>
<td>不需要</td>
</tr>
</tbody>
</table>
<p>顺便说一下，Agent 对象里并没有"装"着 AI 模型，它存的只是模型名字，比如 <code>"gpt-4o"</code>。真正的模型在别人的服务器上。</p>
<p>写成分式的话，Agent 对象差不多等于 instructions、functions 这些外置内容，再加上一个"用哪个模型"的指针。</p>
<h2>4. 转交给哪个 Agent，谁说了算</h2>
<p>可能你会想：AI 模型和智能体对象，谁才是 Agent？又是谁判断什么时候调用哪个专家？</p>
<p>智能体对象才是 Agent。AI 模型是它调用的推理引擎。</p>
<p>第二问没有单一答案，因为这件事被拆成了三段：</p>
<p><img src="./handoff-division.webp" alt="handoff 的三段分工" /></p>
<p><em>图：转交过程中的三个环节。</em></p>
<table>
<thead>
<tr>
<th>环节</th>
<th>谁负责</th>
<th>靠什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>要不要触发转交</strong></td>
<td><strong>AI 模型</strong></td>
<td>算力判断：用户说「我要退款」，该调 <code>transfer_to_refund_agent</code></td>
</tr>
<tr>
<td><strong>转交给谁</strong></td>
<td><strong>程序员</strong></td>
<td>写死的函数返回值：<code>def transfer_to_refund_agent(): return refund_agent</code></td>
</tr>
<tr>
<td><strong>转交动作本身</strong></td>
<td><strong>Swarm 框架</strong></td>
<td>执行函数 → 更新 <code>active_agent</code> → 下一轮用新 Agent 的配置</td>
</tr>
</tbody>
</table>
<p>AI 模型看不到"会转到哪个 Agent"。它能看到的只是这个函数的名字和文档字符串，所以它决定的是调不调，至于调完之后去哪，不归它管。</p>
<p>这点很容易说糊涂。我第一次讲的时候直接说"是 AI 决定找哪个专家"，后来发现不对。AI 模型决定要不要触发转交，程序员决定转交给谁。</p>
<p>如果程序员不写那个函数，AI 模型就永远转交不了，因为它的工具列表里没有这个选项。</p>
<h1>第二部分　context_variables 到底是个什么东西</h1>
<h2>我感觉还说先把结论给你们，下面的定义有点太多了
如果是LangChain的多轮对话记忆
你只是想让 Chatbot 记住刚才聊了什么，实现多轮连续对话。
你的需求是标准的“记住聊天记录”，不需要在对话历史之外维护额外的业务变量。
作为快速原型，先用一行代码验证对话记忆的效果。</h2>
<h2>如果需要操作“上下文变量”（自定义 State）的场景：
多步骤任务：Agent 需要在运行中记录中间结果（如先搜资料 A，再结合资料 B 写报告），这些中间变量必须存在 State 中，供后续步骤读取
动态行为：你需要根据用户是否登录、对话轮数等变量，动态调整系统提示词或可用工具。
工具交互：工具函数需要读取或写入会话级别的特定变量（如当前任务 ID），而不仅仅是收发消息。</h2>
<h2>1. 你传进去的字典，Swarm 会先复制一份</h2>
<pre><code>def run(...):
    active_agent = agent
    context_variables = copy.deepcopy(context_variables)   # ← 这里
    while ...:
        completion = self.get_chat_completion(
            agent=active_agent,
            context_variables=context_variables,           # 传的是副本
            ...
        )
        if message.tool_calls:
            context_variables.update(partial_response.context_variables)
    return Response(context_variables=context_variables)   # 返回被改过的副本
</code></pre>
<p>你传进去的那个字典不会被改动。Swarm 在 run 开头做了 deepcopy，整个循环里传的都是副本；工具函数如果写回了新值，改的也是副本。循环结束时返回给你的，是这份被改过的副本。</p>
<p>想持久化这次运行产生的新数据，得自己从 <code>response.context_variables</code> 里取出来存。</p>
<h2>2. 它只活在一次 run 里</h2>
<p>它就是个普通 Python 字典，<code>client.run()</code> 开始时创建，运行一结束就没了。框架不负责持久化。</p>
<p>可能你会想：用户之前说过一些信息，已经被多 Agent 共享了，我现在关掉网页、刷新重新进来，Agent 还记得吗？</p>
<p>不记得，刷新之后共享状态就清零了。</p>
<p><code>context_variables</code> 只活在单次运行的内存里。刷新网页意味着原来的进程结束了，内存里的字典被回收；新会话调用 <code>client.run()</code> 时你不主动传，Swarm 拿到的就是空字典。</p>
<p>想要"还记得"，这些动作都得自己做：</p>
<pre><code># 存：上次运行结束
response = client.run(...)
db.save(conversation_id, response.context_variables)

# 取：新会话开始
context = db.load(conversation_id)                     # {"name": "小明", "user_id": 456}

# 传：重新塞回去
response = client.run(
    agent=agent,
    messages=load_messages(conversation_id),           # 对话历史也要一起恢复
    context_variables=context,                         # 关键：手动传回
)
</code></pre>
<p>这里 <code>messages</code> 和 <code>context_variables</code> 是两回事。如果你把 <code>messages</code> 恢复了，AI 能从文本里看到之前聊过什么，但那是 AI 在读文本，不代表 <code>context_variables</code> 被恢复了。</p>
<h1>第三部分　那两种「记忆」的区别</h1>
<h2>1. 它们负责的事不一样</h2>
<p>LangChain 的 Memory 关心的是数据存在哪里、怎么持久化。Swarm 的 Context Variables 关心的是同一次运行里，不同组件怎么拿到同一份数据。</p>
<p><img src="./swarm-tool-call-flow.webp" alt="Swarm 单次运行内的完整交互流程" /></p>
<p><em>图：单次 <code>client.run()</code> 里，用户代码、Swarm 框架、AI 模型、功能代码之间如何传递消息和 <code>context_variables</code>。</em></p>
<p>平时大家爱把这两件事概括成"一个管传，一个管存"。这么说确实好记，但容易让人以为 <code>context_variables</code> 不存东西。它其实是存的，<code>context_variables = {"user_id": 456}</code> 就是把 456 存了起来。</p>
<p>所以要真正分清楚，还是得看这份数据能活多久、谁能看见它，以及运行结束后由谁负责把它留下来。</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>Swarm · Context Variables</th>
<th>LangChain · Memory / ChatMemory</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>核心定位</strong></td>
<td>单次运行内的共享状态容器</td>
<td>跨轮次 / 跨会话的对话历史容器</td>
</tr>
<tr>
<td><strong>作用域</strong></td>
<td>一次 <code>client.run()</code> 调用</td>
<td>跨轮次、跨会话</td>
</tr>
<tr>
<td><strong>数据形态</strong></td>
<td>Python <code>dict</code>，几 KB 的结构化字段</td>
<td><code>messages</code> 序列，存对话全文</td>
</tr>
<tr>
<td><strong>生命周期</strong></td>
<td>运行结束即消失，框架不负责落盘</td>
<td>默认在内存；<strong>配了后端</strong>才能持久</td>
</tr>
<tr>
<td><strong>多 Agent 共享</strong></td>
<td><code>handoff</code> 时<strong>自动</strong>跟着走</td>
<td>框架层面<strong>不为此设计</strong>，要共享得自己搭桥</td>
</tr>
</tbody>
</table>
<p><img src="./memory-lifecycle.webp" alt="两种记忆的生命周期对比" /></p>
<p><em>图：两种「记忆」的存活范围。</em></p>
<h2>2. 纠错（I'm Sorry）</h2>
<p>我前面说"LangChain 持久、Swarm 临时"，这个说法不对。</p>
<p>LangChain 的默认实现 <code>ConversationBufferMemory</code> 把数据存在内存里，进程重启、刷新页面同样会丢，默认并不持久。只有当它挂上持久化后端（<code>RedisChatMessageHistory</code>、<code>PostgresChatMessageHistory</code> 这类）之后，数据才写进外部存储，才能跨会话恢复。</p>
<p>"可以持久"和"默认持久"是两回事。真实的区别在于框架有没有给你现成的方案：</p>
<table>
<thead>
<tr>
<th></th>
<th>LangChain Memory</th>
<th>Swarm context_variables</th>
</tr>
</thead>
<tbody>
<tr>
<td>默认存哪</td>
<td>内存</td>
<td>内存</td>
</tr>
<tr>
<td>刷新后默认记得吗</td>
<td><strong>不记得</strong></td>
<td><strong>不记得</strong></td>
</tr>
<tr>
<td>框架提供持久化后端吗</td>
<td><strong>提供</strong>，换个 Memory 类就能接数据库</td>
<td><strong>不提供</strong>，全靠自己写</td>
</tr>
<tr>
<td>要实现持久得写多少代码</td>
<td>配置一下</td>
<td>自己写「存 / 取 / 传」三套逻辑</td>
</tr>
<tr>
<td>主要存什么</td>
<td>对话消息历史（messages）</td>
<td>结构化共享字段</td>
</tr>
</tbody>
</table>
<p>:::important 真正的差别
两者默认都不持久，刷新后都不记得。区别是 LangChain 提供了接数据库就能用的现成方案，Swarm 需要你自己写。
:::</p>
<h2>3. 那到底该用哪个</h2>
<p>"既然都要自己持久化，那我直接用 LangChain 的 ChatMemory 不就行了"。这个判断其实不太成立，因为它们解决的不是同一个层面的问题。</p>
<p>如果你要的是对话历史的持久化，比如刷新后还能看到之前聊过什么，那 LangChain 的 Memory 加持久化后端确实更省事。如果你要的是多 Agent 之间共享结构化状态，比如 <code>user_id</code>、账户类型这种字段，又不想把它们塞进对话文本里，那 <code>context_variables</code> 是原生设计，LangChain 反而没有直接对应的机制。</p>
<p>两者不是替代关系。你可以在 LangChain 里也维护一份类似的共享字典，或者在 Swarm 外面套一层 LangChain 的持久化消息历史。</p>
<h2>4. 什么该进 messages，什么该进 context_variables</h2>
<p>可能你会想：假设用户一口气说了"我叫小明，我在 A 高中上学，周末要和小美去约会，晚上还得写作业，帮我找个近点的好吃的店"，这段东西我该往 <code>context_variables</code> 里塞什么？</p>
<p>这段文本本身，什么都不用塞。</p>
<p><img src="./memory-boundary.webp" alt="messages 与 context_variables 的职责边界" /></p>
<p><em>图：两种数据的去向。</em></p>
<p>把完整对话交给模型，它自己就能读出用户叫小明、在 A 高中、赶时间。这些信息不需要你提前结构化进字典。只有当某个信息需要被跨 Agent、跨函数显式传递时，才需要把它放进 <code>context_variables</code>。</p>
<p>这里最关键的一点是，"提取"这个动作是开发者做的，Swarm 不参与。它不会替你从原文里挑字段，只负责把字典传进 <code>run()</code>、handoff 时带着它走、把工具函数写回的值合并进去。</p>
<p>字段的来源通常有几种：你自己写规则提取、调一次 LLM 做结构化抽取，或者让工具函数通过 <code>Result</code> 写回。</p>
<h2>5. 两边的边界都在哪</h2>
<p>Context Variables 这边不太能指望的：</p>
<ul>
<li><strong>不自动提取</strong>，里面放什么完全由你和你的函数决定；</li>
<li><strong>只适合预设场景</strong>，你得提前知道有哪些字段要共享，比如客服、订票、账户查询这类流程固定的业务；</li>
<li><strong>不适合理解开放文本</strong>，用户输入完全开放时提前定义字段是徒劳的，这种场景交给 <code>messages</code>；</li>
<li><strong>不持久化</strong>，运行结束就没了；</li>
<li><strong>容量小</strong>，官方定位是 KB 级，塞大段文本会拖慢执行，因为它会被传给 instructions 函数和所有工具函数。</li>
</ul>
<p>Memory / ChatMemory 这边不太能指望的：</p>
<ul>
<li><strong>持久化需要配置</strong>，不是自带的；</li>
<li><strong>不负责跨 Agent 共享</strong>，LangChain 的多 Agent 通常走工具调用和任务分发，各子 Agent 有自己的上下文，要共享得自己在应用层搭桥；</li>
<li><strong>存全文会膨胀</strong>，对话越长，token 成本和延迟越高，通常还需要自己裁剪、摘要或检索；</li>
<li><strong>它管"说过什么"，不管"关键字段是什么"</strong>，想要一个可靠的 <code>user_id</code>，还是得自己结构化出来。</li>
</ul>
<h2>6. 实际项目里怎么搭</h2>
<pre><code># 1) 取出「说过什么」和「关键字段」，这两份都由你自己持久化
messages = load_messages_from_db(conversation_id)
context = load_context_from_db(conversation_id)   # {"user_id": 123, "name": "张三"}

# 2) 关键字段交给 Swarm，让它在本次运行里自动传给各 Agent / 函数
response = client.run(agent=agent, messages=messages, context_variables=context)

# 3) 运行结束，两份分别存回去
save_messages_to_db(response.messages)            # 对话全文 → ChatMemory 的职责
save_context_to_db(response.context_variables)    # 关键字段 → 自己持久化
</code></pre>
<h1>最后</h1>
<p>Swarm 这套东西里，Agent 负责声明，AI 模型负责判断，Swarm 负责执行。调不调工具由模型决定，调完之后去哪由程序员的代码决定，具体执行交给 Swarm。</p>
<p><code>context_variables</code> 只在单次运行内有效，随 handoff 流动，运行完就没了。它不会替你提取字段，想持久化得自己存、取、传。LangChain 的 Memory 默认也在内存里，刷新同样会丢，它比 Swarm 多的是现成的持久化插件。开放文本的理解交给 <code>messages</code>，只有已经确定、并且需要跨组件显式共享的字段，才放进 <code>context_variables</code>。</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>从 Swarm 入门多 Agent 协作：我第一次接触到的极简框架</title>
    <link href="https://cnyicheng.top/posts/swarm-minimal-multi-agent/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/swarm-minimal-multi-agent/</id>
    <published>2026-09-20T00:00:00.000Z</published>
    <updated>2026-09-20T00:00:00.000Z</updated>
    <summary>整理旧笔记时翻到了第一次接触多 Agent 协作时的 Swarm 框架图。它简单得近乎原始，却刚好把 Agent、工具调用、上下文与 Handoff 串了起来。本文用它作为切入点，聊聊多 Agent 协作的最小骨架（孩子别回头，放心我不是什么好人）。</summary>
    <content type="html"><![CDATA[<p>整理旧笔记时，我翻到了自己第一次接触到多 Agent 协作时保存的一张 Swarm 框架图。</p>
<p>那会儿我对 Agent 的理解还很模糊：会调几个 API、能给模型挂一两个工具，就已经觉得很厉害了。可看到 Swarm 的那张图时，我第一次意识到，多 Agent 协作并不一定意味着复杂的平台、状态图和层层封装，它也可以简单到只剩几个角色、一条消息链和一次“交接”。</p>
<p>后来 Agent 生态越来越热闹，LangGraph、CrewAI、AutoGen、各家云厂商的编排平台轮番出现，概念也越来越多：状态、持久化、人工审核、可观测性、评测。这些当然重要，但我还是会时不时想起最初看到的那个极简框架。</p>
<p>所以这篇文章，我想把 Swarm 当作一个入口，带大家先看懂多 Agent 协作的最小骨架。</p>
<p>:::note 本文适合刚接触多Agent协作的朋友们
如果你已经会写简单的模型调用，但对“多 Agent 协作”还停留在概念层面，这篇会帮你先建立一张清晰的控制流图。它不是生产级框架教程，而是一篇入门地图。
:::</p>
<p><img src="./swarm-architecture.webp" alt="Swarm 框架图：从 Swarm 调度到 Agent、工具、上下文与响应" /></p>
<p><em>图：Swarm 框架图，来自我第一次接触多 Agent 协作时的资料。</em></p>
<h2>一、Swarm 是什么？</h2>
<p>Swarm 是 OpenAI 开源的一个实验性、教育性质的多 Agent 框架。它最大的特点不是功能多，而是<strong>足够小</strong>。</p>
<p>小到什么程度？</p>
<p>它主要提供几个非常朴素的抽象：</p>
<ul>
<li><code>Agent</code>：一个角色，包含系统指令和可用工具；</li>
<li><code>Handoff</code>：一次交接，把当前任务和控制权交给下一个 Agent；</li>
<li><code>Context / Messages</code>：一段在协作过程中被不断读写的共享上下文；</li>
<li><code>run()</code>：框架循环，负责把消息交给模型、执行模型要求调用的工具、处理交接，直到得出最终回复。</li>
</ul>
<p>Swarm 不会替模型决定“该调用哪个工具”，它做的是另一件事：</p>
<blockquote>
<p>把工具清单和消息历史交给模型，模型来决定下一步；Swarm 负责忠实执行。</p>
</blockquote>
<p>这也是它容易被误解的地方。乍看之下，会以为 Swarm 是一个“智能调度器”；实际上，真正的决策发生在大模型里，Swarm 更像一个轻量级的执行器和传令官。</p>
<h2>二、Swarm 和 Agent 是什么关系？</h2>
<p>可以把它想成一个项目调度中心。</p>
<ul>
<li><strong>Swarm</strong> 是调度台：接收任务、准备上下文、把工具说明交给模型、执行工具调用、处理 Agent 之间的交接。</li>
<li><strong>Agent</strong> 是拥有不同专长的员工：模型选择哪个 Agent，当前 Agent 选择调用哪个工具，或者把任务交给谁，都是模型基于上下文做出的决策。</li>
<li><strong>工具</strong> 是员工可用的外部能力：查天气、查数据库、发邮件、调接口。</li>
<li><strong>上下文变量</strong> 是共享白板：A 查到的信息写进去，B 和 C 都能读到。</li>
</ul>
<blockquote>
<p>Swarm 是舞台和导演，Agent 是演员。没有 Agent，Swarm 只是空壳；没有 Swarm，Agent 很难共享记忆、工具和交接秩序。</p>
</blockquote>
<h2>三、用 A、B、C 三个 Agent 走一遍交接过程</h2>
<p>假设用户发来一个任务：</p>
<blockquote>
<p>帮我查一下明天的天气，根据天气推荐一套穿搭，最后为这套穿搭写一首短诗。</p>
</blockquote>
<p>Swarm 里恰好有三个 Agent：</p>
<ul>
<li>Agent A：天气查询</li>
<li>Agent B：穿搭推荐</li>
<li>Agent C：写诗</li>
</ul>
<p>执行过程可以拆成下面几步。</p>
<h3>1. Swarm 接收任务</h3>
<p>Swarm 拿到用户消息，先把任务交给当前 Agent A。</p>
<h3>2. A 调用工具，并写入上下文</h3>
<p>A 调用天气查询工具，拿到结果：</p>
<pre><code>明天晴，25℃
</code></pre>
<p>这个结果会进入共享上下文。</p>
<h3>3. A 判断自己不适合继续，发起 Handoff</h3>
<p>A 发现自己只会查天气，不会推荐穿搭，于是把下一节点指向 Agent B。</p>
<p>这就是 Swarm 里最重要的动作：<strong>交接（Handoff）</strong>。</p>
<h3>4. B 读取上下文，继续处理</h3>
<p>B 从共享上下文里读到天气信息，调用穿搭推荐工具，得到结果；然后把结果写回上下文，再把下一节点指向 Agent C。</p>
<h3>5. C 完成最后一步，返回响应</h3>
<p>C 读取天气和穿搭信息，调用写诗工具，完成最终内容。此时不再交接，流程回到 Swarm 的返回分支，最终响应被交还给用户。</p>
<p>整个过程可以画成一条接力链：</p>
<pre><code>用户
  ↓
Swarm
  ↓
Agent A（天气）
  ↓ Handoff
Agent B（穿搭）
  ↓ Handoff
Agent C（写诗）
  ↓
Swarm 返回响应
</code></pre>
<p>有四个关键点：</p>
<ol>
<li><strong>它是串行的，不是并发的。</strong> 控制权在同一时刻只在一个 Agent 手里，像接力赛，不是三个人同时跑。</li>
<li><strong>上下文是共享的。</strong> A 的结果能变成 B 的输入，B 的结果又能变成 C 的输入。</li>
<li><strong>交接是显式的。</strong> Agent 不直接“命令”另一个 Agent，而是把下一节点交给 Swarm，由框架完成切换。</li>
<li><strong>它可以是循环的。</strong> 如果 A 觉得天气没查清楚，也可以把下一节点指回自己，形成重试循环。</li>
</ol>
<h2>四、Swarm 真正的核心逻辑</h2>
<p>把 Swarm 的实现抽象出来，大概就是一个很朴素的循环：</p>
<pre><code>while True:
    # 1. 把当前 Agent 的指令、上下文和工具清单交给模型
    response = model(messages, tools=agent.tools)

    # 2. 记录模型回复
    messages.append(response)

    # 3. 如果模型要求调用工具，就执行工具，把结果写回上下文
    if response.tool_calls:
        for call in response.tool_calls:
            result = call_tool(call)
            messages.append(result)
        continue

    # 4. 如果模型决定交接给另一个 Agent，就切换当前 Agent
    if response.handoff:
        agent = response.handoff
        continue

    # 5. 没有工具调用、也没有交接，就返回最终结果
    return response
</code></pre>
<p>这段代码不是 Swarm 的逐行源码，但它把关键结构说清楚了：</p>
<ul>
<li>工具是否调用，由模型决定；</li>
<li>调用哪个工具，由模型决定；</li>
<li>是否要交接给其他 Agent，也由模型决定；</li>
<li>Swarm 自己不“思考”，它只负责把模型的决定翻译成动作，并维护消息和上下文的流动。</li>
</ul>
<p>这也解释了为什么 Swarm 看起来不像 Spring AI、LangGraph 那样的“系统框架”。它更像一份带执行能力的教学参考实现：把多 Agent 协作最核心的 Tool Calling、Context 和 Handoff 暴露出来，不替你加太多抽象。</p>
<h2>五、为什么我觉得它适合入门 Agent？</h2>
<p>因为它让人先看到“骨架”，再谈“肌肉”。</p>
<p>很多人第一次接触 Agent 时，会直接掉进概念堆里：</p>
<ul>
<li>什么是 State Graph？</li>
<li>什么是 Checkpointer？</li>
<li>什么是 Human-in-the-loop？</li>
<li>怎么接观测、评测、权限和审计？</li>
</ul>
<p>这些问题当然要回答，但如果一开始就陷进工程细节，很容易变成“学会了框架 API，却不知道 Agent 到底怎么运转”。</p>
<p>Swarm 的价值在于，它足够简单，简单到你可以在脑子里跑完整个流程：</p>
<pre><code>模型 → 工具调用 → 结果写回上下文 → 判断下一步 → 交接或返回
</code></pre>
<p>一旦这条链路清楚了，再看 LangGraph、CrewAI 这些更成熟的框架，就会很容易理解它们分别在补什么：状态图补控制流，持久化补长任务，记忆组件补长期上下文，可观测性补排查，企业平台补治理和安全。</p>
<p>所以我更愿意把 Swarm 当作一块踏板：</p>
<blockquote>
<p>先用手写最小循环理解 Agent 的本质，再看生产级框架如何把原型变成系统。</p>
</blockquote>
<h2>六、Swarm 不是企业级答案，但它留下了重要的一课</h2>
<p>必须强调，Swarm 本身并不适合直接上生产。</p>
<p>它是轻量实验框架，缺少企业场景需要的东西：</p>
<ul>
<li>跨请求的状态与持久化；</li>
<li>长任务恢复与失败重试；</li>
<li>权限、审计、安全边界；</li>
<li>人工审核节点；</li>
<li>完整的可观测性和评测体系；</li>
<li>大规模工具与 Agent 的动态治理。</li>
</ul>
<p>所以真实企业系统通常不会拿 Swarm 直接扛业务，而会使用更成熟的编排框架或云平台。</p>
<p>但这并不代表 Swarm 没有价值。它留下的 <strong>Handoff</strong> 这个抽象，已经被后来的很多 Agent 框架继承。企业级方案做的不是推翻它，而是在它的思路上补上状态、记忆、治理和工程能力。</p>
<blockquote>
<p>Swarm 提供的是灵魂，成熟框架提供的是钢铁之躯。</p>
</blockquote>
<h2>七、如果让我给一条入门路线</h2>
<p>如果读者想从这篇文章出发继续学 Agent，我会建议这个顺序：</p>
<ol>
<li><strong>先手写一次 Tool Calling 循环。</strong> 不借助框架，自己维护 <code>messages</code>，让模型调用工具，再把结果喂回去。</li>
<li><strong>加入 Handoff。</strong> 定义多个角色，让模型在需要时把任务交给下一个角色。</li>
<li><strong>加入共享上下文。</strong> 让后一个 Agent 能读到前一个 Agent 的结果。</li>
<li><strong>补上状态和持久化。</strong> 思考一次任务跨多轮、跨进程后该如何恢复。</li>
<li><strong>再看成熟框架。</strong> 这时再看 LangGraph、CrewAI、AutoGen 或企业平台，你会更清楚它们为什么存在。</li>
<li><strong>最后再谈生产。</strong> 权限、审计、评测、监控和成本控制，都是进入生产前必须补的课。</li>
</ol>
<h2>八、结语</h2>
<p>第一次看到 Swarm 时，感觉这个写法没有办法和主流框架去比，但是就好像篮球教练和科比一样，篮球教练可以让你更快上手（什么奇妙的例子，算了我不举了）。</p>
<p>后来才明白，它的价值恰恰在于把多 Agent 协作剥到最薄：一个调度循环、几个角色、一条上下文、几次交接。它不试图解决所有问题，而是让初学者先把最核心的问题看清楚。</p>
<p>今天再回头看，我依然觉得它是很多人入门 Agent 最好的切口之一：</p>
<blockquote>
<p>不要一上来就学怎么搭建复杂的 Agent 系统，先看懂一个 Agent 为什么能调用工具、为什么要把任务交给另一个 Agent、以及上下文是如何在它们之间流动的。</p>
</blockquote>
<p>等这套最小骨架在脑子里跑通之后，再去接触生产级框架，路会清楚很多。</p>
<blockquote>
<p>声明：本片文章参考博主lvy010的博客https://lvynote.blog.csdn.net/?type=blog内容，大家如果感兴趣可以关注一下，lvy牛逼！</p>
</blockquote>
<hr />
<h3>参考资料</h3>
<ul>
<li><a href="https://github.com/openai/swarm">OpenAI Swarm</a></li>
<li><a href="https://blog.csdn.net/2301_80171004/article/details/149199018">CSDN：Swarm 框架入门相关文章</a></li>
</ul>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>第二章 （王）从（天降，愤怒狰狞） Socket 到 FastAPI</title>
    <link href="https://cnyicheng.top/posts/from-socket-to-fastapi/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/from-socket-to-fastapi/</id>
    <published>2026-09-17T00:00:00.000Z</published>
    <updated>2026-09-17T00:00:00.000Z</updated>
    <summary>承接上一篇爬虫教程：先把协议、Socket 和 HTTP 到底是怎么回事讲清楚，再用 FastAPI 写下你的第一个接口。</summary>
    <content type="html"><![CDATA[<p>上一篇我们写了一个能跑起来的爬虫，你可以暂时理解为，用的是 <code>requests.get(url)</code>、<code>headers</code> 伪装、<code>resp.encoding</code> 这些。</p>
<p>但说实话，那时候我们是在<strong>照抄</strong>：知道这样写能跑通，却不知道底下发生了什么。<code>.get()</code> 里的 <code>get</code> 为什么是 <code>get</code>？<code>headers</code> 里的 <code>User-Agent</code> 到底是给谁看的？"请求"这个词，物理上究竟指什么东西？</p>
<p>为了解决这些烦恼，我写了这篇内容</p>
<p>:::note 读完你应该能做到</p>
<ul>
<li>弄懂"协议"到底是什么，为什么没有他就没法通信</li>
<li>Socket 是什么、他在最基础的应用层是干啥的</li>
<li>了解 HTTP 请求</li>
<li>分清 GET / POST、路径参数 / 查询参数</li>
<li>用 FastAPI 跑起你的第一个接口，并在 <code>/docs</code> 里调试它（Fast API在后续的应用方向主要为Python后端+Agent方向，如果你的最终目的是为了找一份还不错的工作在大学期间提升知识储备，对计算机底层，系统内核等内容没有兴趣，我推荐你学他，这是目前半淘汰掉Java后较为主流的岗位，如果你是比较喜欢研究计算机底层原理而非为了就业目的观摩本篇暂时不要学这个了，先去夯实相关基础知识吧）
:::</li>
</ul>
<hr />
<h1>第一部分　地基：协议与 Socket</h1>
<h2>1. 协议：两个人说话，总得先约好</h2>
<p>先问一个很傻但很重要的问题：<strong>两台电脑之间靠什么才算"能通信"？</strong></p>
<p>答案是：不是靠"能连上"，而是靠<strong>双方都认可的那套规矩</strong>。</p>
<p>举个生活中的例子。你跟朋友约定"我说'开始'你就跑"，那么你喊"开始"他才会跑；如果你临时喊一声"冲啊"，他可能愣在原地，不是他没听见，而是<strong>你们约定的词对不上，他没法解包</strong>。</p>
<p>计算机之间也是这个道理：</p>
<ul>
<li><strong>发送方</strong>必须按某个格式把数据打包</li>
<li><strong>接收方</strong>必须按同一个格式把数据解开</li>
<li>这个"双方都认的格式约定"，就叫<strong>协议（Protocol）</strong></li>
</ul>
<p>:::tip 协议是什么
协议 = 通信双方对「怎么发、发什么格式、收到怎么解」达成的共同约定。
:::</p>
<p>这也是为什么后面会反复强调，<strong>格式是不能改的</strong>。你不按约定的协议发，服务器那边根本没法解包；反过来，服务器不按约定打包，浏览器也一样解不开。</p>
<h2>2. Socket：程序之间那根看不见的管子</h2>
<h3>2.1 没有 Socket 之前，程序都是"单机程序"</h3>
<p>在没有 Socket 的年代，程序基本只能跟自己和本地文件打交道。它想让<strong>另一台机器上的另一个程序</strong>收到点东西，就得自己去处理网卡、网线、信号、重传、校验……这显然不现实。</p>
<p>于是操作系统站出来了：<strong>我把底下那些麻烦事全包了，给你一个简单的接口，你只管往里写数据。</strong></p>
<p>这个接口就是 Socket。</p>
<h3>2.2 一次 "hello world" 的旅程</h3>
<p>假设 A 想给 B 发一句 <code>hello world</code>。我们自己写代码时只是 <code>send("hello world")</code> 或者 <code>printf("hello world")</code>，但这句话实际上要走完下面这条路：</p>
<pre><code>flowchart LR
    A["A 的应用层hello world"] --&gt; B["传输层加 TCP 头"]
    B --&gt; C["网络层加 IP 头"]
    C --&gt; D["链路层加帧头"]
    D --&gt; E["物理层变成电信号 / 光信号"]
    E --&gt; F["B 的物理层"]
    F --&gt; G["B 的链路层"]
    G --&gt; H["B 的网络层"]
    H --&gt; I["B 的传输层"]
    I --&gt; J["B 的应用层hello world"]
</code></pre>
<p>每一层都在原始数据外面<strong>加一层自己的头</strong>（这个过程叫封装），到了对面再<strong>一层层拆开</strong>。</p>
<p>你可以把它想象成寄快递：你的信是"数据"，装进信封写明收件人是"传输层头"，再装进快递袋写明区域分拣是"网络层头"，最后扔上货车走公路是"物理层"。收件人那边反过来拆。</p>
<h3>2.3 Socket 抽象掉了什么</h3>
<p>如果每发一句话都要你自己去处理 TCP 头、IP 头、帧头、电信号，那写 Web 程序的人得疯。</p>
<p><strong>Socket 的作用，就是把这底下四层整体收起来，对外只给你一个"管子"。</strong></p>
<pre><code>flowchart TB
    subgraph APP["应用层 —— 你写代码的地方"]
        A1["HTTP / HTTPS / WebSocket 等"]
    end
    subgraph SOCK["Socket 接口 —— 操作系统提供"]
        S1["socket() · connect() · send() · recv()"]
    end
    subgraph LOW["下面这几层 —— Socket 帮你挡住"]
        T["传输层　TCP / UDP（端口在这里）"]
        N["网络层　IP（IP 地址在这里）"]
        L["链路层　网卡 / MAC 地址"]
        P["物理层　光纤 / 电缆 / 无线电"]
    end
    APP --&gt; SOCK
    SOCK --&gt; T
    T --&gt; N
    N --&gt; L
    L --&gt; P
</code></pre>
<p>:::important 一个关键认知
<strong>Socket 发送出去的，本质上是一串字符串/字节。</strong></p>
<p>Socket 自己<strong>完全不关心</strong>这串字符是什么意思，它是 <code>hello world</code>，还是一段 JSON，还是一张图片的前半截，对 Socket 来说都一样。至于"这串字符代表什么、该怎么解释"，那是<strong>上层的协议</strong>（比如 HTTP）该管的事。</p>
<p>这就是为什么下一篇讲 HTTP 之前，得先把 Socket 讲明白：<strong>HTTP 是跑在 Socket 之上的"约定"，Socket 只负责把字节送过去。</strong>
:::</p>
<h2>3. TCP/IP 四层模型</h2>
<p>为了方便描述，人们把整条链路拆成几层。我们现在要用的 HTTP，就是<strong>建立在 TCP/IP 之上</strong>的。</p>
<pre><code>flowchart TB
    L4["应用层HTTP · HTTPS · DNS · FTP"]
    L3["传输TCP · UDP（端口）"]
    L2["网络层IP（IP 地址、路由）"]
    L1["链路层网卡 · MAC 地址 · 物理媒介"]
    L4 --&gt; L3 --&gt; L2 --&gt; L1
</code></pre>
<table>
<thead>
<tr>
<th>层</th>
<th>它负责什么</th>
<th>关键名词</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>应用层</strong></td>
<td>规定"这些字节是什么意思"</td>
<td>HTTP、HTTPS、DNS</td>
</tr>
<tr>
<td><strong>传输层</strong></td>
<td>端到端地把数据送到<strong>某个程序</strong>（不是某台机器）</td>
<td>TCP、UDP、<strong>端口</strong></td>
</tr>
<tr>
<td><strong>网络层</strong></td>
<td>在<strong>不同网络之间</strong>找路、转发</td>
<td>IP、IP 地址、数据包、路由</td>
</tr>
<tr>
<td><strong>链路层</strong></td>
<td>在一段物理网络上把数据搬过去</td>
<td>网卡、MAC 地址、光纤/电缆/无线</td>
</tr>
</tbody>
</table>
<p>日常说的"物理层"，在这套四层模型里被归进链路层一起讲了：它指的就是<strong>真正负责传信号的硬件</strong>：光纤、电缆、无线信号这些。</p>
<h3>3.1 网卡和 MAC地址（这个Mac并不是巧不思在美国创建的Bpple公司的Mac系列产品）</h3>
<p><strong>网卡（Network Interface Card）</strong> 是设备连接网络的硬件组件，负责接收和发送数据包，并通过有线或无线方式接入局域网或互联网。</p>
<p>网卡需要驱动程序才能工作：操作系统通过驱动程序给网卡下命令。驱动大致负责四件事：</p>
<ul>
<li><strong>通信管理</strong>：数据包的收发</li>
<li><strong>性能优化</strong>：缓存、分段、重组，提升传输效率</li>
<li><strong>兼容性支持</strong>：不同品牌型号的网卡需要各自的驱动</li>
<li><strong>错误检测</strong>：发现碰撞、丢包等问题并处理或上报</li>
</ul>
<p><strong>MAC 地址</strong>则是网卡出厂自带的、类似身份号的物理地址，它是"这一块网卡"的身份，而不是"这台设备在互联网上的门牌号"（后者是 IP 地址）。</p>
<h3>3.2 TCP 和 UDP</h3>
<p>两者都在传输层，但脾气完全不同：</p>
<table>
<thead>
<tr>
<th></th>
<th>TCP</th>
<th>UDP</th>
</tr>
</thead>
<tbody>
<tr>
<td>特点</td>
<td>可靠、有序、面向连接</td>
<td>不保证到达、不保证顺序、无连接</td>
</tr>
<tr>
<td>适合</td>
<td>网页、文件、登录（<strong>不能丢数据</strong>）</td>
<td>直播、游戏、语音（<strong>要快，丢一点没关系</strong>）</td>
</tr>
<tr>
<td>代价</td>
<td>慢一些（要握手、要重传）</td>
<td>快，但可能丢包</td>
</tr>
</tbody>
</table>
<p>HTTP 选择的是 <strong>TCP</strong>。所以你打开网页时，数据是会被可靠、按顺序送到的。</p>
<h2>4. 什么是 HTTP 协议</h2>
<h3>4.1 超文本传输协议</h3>
<p><strong>HTTP = HyperText Transfer Protocol，超文本传输协议。</strong></p>
<p>"超文本"这个词容易让人误会成"只能传文字"。实际上它指的是"不止是纯文本"：图片、音频、视频、JSON 都能传。准确的说法是，<strong>它不限于传输文本字符串</strong>。</p>
<h3>4.2 谁和谁达成的协议</h3>
<p>HTTP 是**客户端（发送方）<strong>和</strong>服务器（接收方）**之间达成的约定。</p>
<ul>
<li><strong>客户端</strong>：绝大多数情况下是浏览器；但也可以是 <code>requests</code>、<code>curl</code>、你写的爬虫，<strong>只要是主动发起请求的一方，就是客户端</strong></li>
<li><strong>服务器</strong>：负责接收请求、处理、把结果打包回传</li>
</ul>
<p>:::note 一个反直觉的点
在 HTTP 里，<strong>客户端和服务器不是平等的</strong>。服务器没有"主动给你发消息"的能力，它只能等你来问，然后回答。
:::</p>
<h3>4.3 HTTP 的四个关键特性</h3>
<p>:::steps[HTTP 的四个特性]</p>
<ol>
<li>
<p><strong>基于 TCP/IP</strong></p>
<p>底层用 TCP 保证可靠传输。上面讲的那四层，HTTP 就坐在最顶上。</p>
</li>
<li>
<p><strong>请求-响应模式</strong></p>
<p>只能由客户端先发起请求，服务器<strong>必须</strong>给出响应。原本两台机器可以互相随便发消息，但 HTTP 把这件事规定成了"一问一答"。</p>
</li>
<li>
<p><strong>无状态</strong></p>
<p>服务器<strong>不会记住你上一次来过</strong>。你这次访问，它不会知道你已经访问过几次、之前问过什么。每次请求在服务器眼里都是"第一次见面"。</p>
</li>
<li>
<p><strong>有长连接和短连接之分</strong></p>
<ul>
<li>HTTP/1.0 默认<strong>短连接</strong>：连上、传输、断开。连接是有限资源，不会让你一直占着，等下次访问再重新连。</li>
<li>HTTP/1.1 默认<strong>长连接</strong>：一次连接可以连续处理多个请求，省掉反复握手。
:::</li>
</ul>
</li>
</ol>
<p>:::details 无状态带来的问题：那我登录之后为什么没被踢出去？
因为<strong>无状态不等于无记忆</strong>。服务器确实不记你，但客户端可以"自带身份证明"，这就是 Cookie / Session / Token 存在的意义。浏览器每次都把凭证捎上，服务器一看就知道你是谁。HTTP 本身依然是无状态的。
:::</p>
<hr />
<h1>第二部分　把 HTTP 报文拆开看</h1>
<p>HTTP 通信时，双方来回传的东西叫<strong>报文</strong>。请求有请求报文，响应有响应报文，格式都是定死的。</p>
<h2>5. 请求报文的格式</h2>
<pre><code>flowchart TB
    R1["请求首行方法 + 路径和参数 + 协议版本"]
    R2["请求头一行一个 key: value"]
    R3["空行作为分隔符"]
    R4["请求体真正要传的数据"]
    R1 --&gt; R2 --&gt; R3 --&gt; R4
</code></pre>
<h3>5.1 请求首行（三个部分）</h3>
<pre><code>POST /login?from=web     HTTP/1.1
└─┬┘ └───┬───┘ └──┬──┘
方法   路径 + 参数         协议版本
</code></pre>
<ul>
<li><strong>方法</strong>：要干什么（GET / POST / PUT / DELETE …）</li>
<li><strong>路径</strong>：访问哪个资源；路径和参数之间用 <code>?</code> 隔开，没有参数也行</li>
<li><strong>协议版本</strong>：用哪个版本的 HTTP</li>
</ul>
<h3>5.2 请求头</h3>
<p>请求首行之后，是一行一个的键值对，<strong>有多少行不固定，具体看浏览器</strong>。</p>
<p>每一个请求头都是在给服务器递一张"说明卡片"：</p>
<table>
<thead>
<tr>
<th>请求头</th>
<th>它在说什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Content-Type</code></td>
<td>我这次发的数据是什么格式（比如 JSON）</td>
</tr>
<tr>
<td><code>User-Agent</code></td>
<td>我是谁——什么操作系统、什么浏览器</td>
</tr>
<tr>
<td><code>Host</code></td>
<td>我要访问的是哪个站点</td>
</tr>
<tr>
<td><code>Cookie</code></td>
<td>我身上带着的身份凭证</td>
</tr>
</tbody>
</table>
<p>回想上一篇爬虫里的 <code>User-Agent</code> 伪装，现在你应该明白它到底在干嘛了：<strong>我们就是在伪造这张说明卡片，让服务器以为"来的是个浏览器"。</strong></p>
<h3>5.3 空行和请求体</h3>
<p><strong>请求头和请求体之间必须有一个空行</strong>，它是两者的分隔符，不能省。</p>
<p>请求体是真正放数据的地方。POST 请求会把账号、密码这类数据放进请求体里。</p>
<h3>5.4 一个完整的请求示例</h3>
<pre><code>POST /login HTTP/1.1
Host: www.example.com
Content-Type: application/json
Content-Length: 42
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36

{"username": "alice", "password": "123456"}
</code></pre>
<p>注意最后那个空行，它上面是头，下面是体。</p>
<h2>6. GET 和 POST 的区别</h2>
<p>这两兄弟的区别，<strong>根本上是"数据放哪儿"</strong>：</p>
<table>
<thead>
<tr>
<th></th>
<th>GET</th>
<th>POST</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据放哪</td>
<td>放在 <strong>URL 里</strong>（路径的 <code>?</code> 后面）</td>
<td>放在<strong>请求体</strong>里</td>
</tr>
<tr>
<td>容量</td>
<td>有限（地址栏能装的东西有限）</td>
<td>大得多</td>
</tr>
<tr>
<td>安全性</td>
<td>差一些（参数直接暴露在 URL 里，会被日志、历史记录留下）</td>
<td>好一些（不在地址栏里）</td>
</tr>
<tr>
<td>典型用途</td>
<td>查询、获取数据</td>
<td>提交、新增数据</td>
</tr>
</tbody>
</table>
<p>:::warning 顺便纠个常见笔误
写文章或搜索时常见有人写成"Git 请求"，Git 是版本控制工具，跟这个没关系。这里说的是 <strong>GET 请求</strong>。
:::</p>
<h2>7. URL 解剖：路径参数与查询参数</h2>
<p>这一节很重要，因为后面 FastAPI 的两种参数类型全靠它。</p>
<p>以一个虚假招聘网站链接为例：</p>
<pre><code>https://www.example.com:443/jobs/python?city=shanghai&amp;years=3-5#top
</code></pre>
<table>
<thead>
<tr>
<th>部分</th>
<th>内容</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>协议</td>
<td><code>https</code></td>
<td>用哪个协议</td>
</tr>
<tr>
<td>域名</td>
<td><code>www.example.com</code></td>
<td>人好记的名字，实际会被解析成 IP 地址</td>
</tr>
<tr>
<td>端口</td>
<td><code>443</code></td>
<td>HTTPS 默认端口（HTTP 是 80），通常会自动省略</td>
</tr>
<tr>
<td>路径</td>
<td><code>/jobs/python</code></td>
<td>访问哪个资源</td>
</tr>
<tr>
<td>查询参数</td>
<td><code>?city=shanghai&amp;years=3-5</code></td>
<td><code>?</code> 之后的键值对，多个用 <code>&amp;</code> 连接</td>
</tr>
<tr>
<td>锚点</td>
<td><code>#top</code></td>
<td>页面内的位置，<strong>不会被发送给服务器</strong></td>
</tr>
</tbody>
</table>
<p>:::note 关于域名
常见误解是"域名就是数字组成的 IP"。其实不是：<strong>域名是给人看的名字</strong>（比如 <code>www.baidu.com</code>），DNS 负责把它翻译成 IP 地址；IP 才是机器用来寻址的数字。你可以把域名理解成通讯录里的名字，IP 理解成电话号码。
:::</p>
<p>对比一下招聘网站的真实参数：</p>
<pre><code>/jobs?xl=本科&amp;gj=3-5年&amp;kd=python
</code></pre>
<p>翻译过来就是"学历=本科、工作经验=3-5 年、关键词=python"，这就是<strong>查询参数</strong>，用来在一个页面里做筛选查询。</p>
<p>而 <code>/jobs/python</code> 里的 <code>python</code>，是"路径里的一截"，属于<strong>路径参数</strong>。</p>
<p>注意：正常来讲你遇到的链接都不长这样，即GET，这样的写法会把数据暴露出来，别人只要拿到这个链接就拿到了数据，比如你的数维网站如果这样存储数据，那么我只要拿到了链接就获得了你的学号+密码，如果高考志愿填报也这样搞那获取你这串链接的人就可以修改你的志愿了</p>
<p>:::tip 记忆口诀</p>
<ul>
<li><code>?</code> <strong>左边</strong>是路径，<code>?</code> <strong>右边</strong>是查询参数</li>
<li>路径参数是"资源的一部分"（哪一份数据），查询参数是"筛选条件"（怎么挑）
:::</li>
</ul>
<h2>8. 响应报文的格式</h2>
<p>服务器收到请求后，要按规定的格式打包回传：</p>
<pre><code>flowchart TB
    S1["状态行协议版本 + 状态码 + 状态说明"]
    S2["响应头一行一个 key: value"]
    S3["空行分隔符"]
    S4["响应体真正的结果数据"]
    S1 --&gt; S2 --&gt; S3 --&gt; S4
</code></pre>
<h3>8.1 状态行与状态码</h3>
<pre><code>HTTP/1.1     200   OK
└──┬──┘ └┬┘ └┬┘
 协议版本 状态码 说明
</code></pre>
<p>状态码告诉客户端"这次怎么样了"：</p>
<table>
<thead>
<tr>
<th>状态码</th>
<th>含义</th>
<th>常见场景</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>2xx</strong></td>
<td>成功</td>
<td><code>200 OK</code>：一切正常</td>
</tr>
<tr>
<td><strong>3xx</strong></td>
<td>重定向</td>
<td><code>301</code>、<code>302</code>：资源搬到别处了</td>
</tr>
<tr>
<td><strong>4xx</strong></td>
<td><strong>你</strong>的问题</td>
<td><code>404</code> 找不到页面、<code>403</code> 没权限、<code>401</code> 没登录</td>
</tr>
<tr>
<td><strong>5xx</strong></td>
<td><strong>服务器</strong>的问题</td>
<td><code>500</code> 服务器内部错误</td>
</tr>
</tbody>
</table>
<h3>8.2 响应头与响应体</h3>
<p>响应头同样是键值对，告诉客户端"我给你的东西是什么、多大、什么格式"：</p>
<pre><code>HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 27

{"token": "abc123", "ok": true}
</code></pre>
<p>响应体就是真正要的数据。<strong>响应体和响应头之间同样用一个空行隔开。</strong></p>
<h2>9. Content-Type：别让服务器猜你的数据格式</h2>
<p>这是非常关键的一个点，值得单独拎出来讲。</p>
<p>设想一下：你发过去一段数据，服务器得<strong>按某种格式去解包</strong>。如果你的数据是 JSON（如果不知道这是什么的同学现在立刻去参拜DeepSeek大王），但服务器按表单格式去解，那必然报错或解析出一堆垃圾。</p>
<p><strong>Content-Type 就是用来解决"怎么解包"这个问题的</strong>，它明确告诉对方"我这包数据是什么格式，请按这个格式解"。</p>
<table>
<thead>
<tr>
<th>Content-Type</th>
<th>数据是什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>application/json</code></td>
<td>JSON</td>
</tr>
<tr>
<td><code>application/x-www-form-urlencoded</code></td>
<td>表单（键值对）</td>
</tr>
<tr>
<td><code>multipart/form-data</code></td>
<td>带文件的表单（上传图片用）</td>
</tr>
<tr>
<td><code>text/html</code></td>
<td>HTML 页面</td>
</tr>
<tr>
<td><code>text/plain</code></td>
<td>纯文本</td>
</tr>
</tbody>
</table>
<p>:::warning 没有它会发生什么
对方只能<strong>猜</strong>。猜错了就是乱码、解析失败、报错。</p>
<p>这跟上一篇爬虫里"编码不对就满屏乱码"是同一类问题，<strong>你不主动声明，对方就没法正确理解你。</strong>
:::</p>
<hr />
<h1>第三部分　从网页到接口：API 与 FastAPI</h1>
<h2>10. API 是什么</h2>
<p><strong>API 接口</strong>的意思是：客户端要访问网站的某个功能，后端的程序员开发的 API 只负责<strong>提供客户要的数据和程序</strong>。</p>
<p>听起来抽象，用"前后端分不分离"来理解最直观（或者现在去B站搜一下API-KEY以及调用API，这也是让你迅速对API有一个初步概念的法子）。</p>
<h3>10.1 前后端不分离</h3>
<p>后端既处理数据、<strong>又</strong>负责把数据渲染进 HTML 页面，然后把整个页面返回给客户端，事先说明，前后端不分离这个概念现在企业开发很少用，你只要了解就好</p>
<pre><code>flowchart LR
    C["浏览器"] --&gt; S["后端&lt;查数据库 + 渲染 HTML"]
    S --&gt; C
</code></pre>
<p>它需要遵循<strong>模板语法</strong>，把数据库里的数据"嵌入"到 HTML 里再返回。好处是简单，坏处是后端要操心页面。</p>
<h3>10.2 前后端分离（）</h3>
<pre><code>flowchart LR
    C["浏览器 / App"] --&gt;|请求数据| B["后端 API只返回 JSON"]
    B --&gt;|JSON| C
    C --&gt; R["前端负责渲染页面"]
</code></pre>
<p>后端从此<strong>不再关心任何一行 HTML 代码</strong>。这正是现代 Web 开发的主流方式，也是 FastAPI 这类框架最擅长的场景。</p>
<h2>11. RESTful：给接口定一套规矩</h2>
<h4>如果你是刚接触编程/计算机，这部分内容对你来讲可能理解起来会很抽象，上面的内容理解后就已经达到了科普基础内容的目的，接下来的内容适合你已经频繁接触编程/计算机一段时间后再来理解</h4>
<p>在开发 API 的过程中，每个程序员的习惯都不一样，导致最终的代码风格千差万别。如果能有一套统一的习惯，别人<strong>大致一看就知道这个接口是干什么的</strong>，那就方便多了，这就是 RESTful 想解决的问题。</p>
<h3>11.1 反面例子</h3>
<p>假设你做了一个学生网站：</p>
<pre><code>http://example.com/student/inquire    # 查询学生
http://example.com/student/add        # 添加学生
http://example.com/student/delete     # 删除学生
</code></pre>
<p>这是初级开发最喜欢的方式，但不够好：<strong>动作被塞进了路径里</strong>，路径越写越长，语义也重复。</p>
<h3>11.2 用 HTTP 方法来表达动作</h3>
<p>把动作从路径里拿出来，路径只表示"操作哪个资源"：</p>
<pre><code>http://example.com/student
</code></pre>
<p>那服务器怎么知道你要干嘛？<strong>看请求方法</strong>：</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>用来做什么</th>
<th>例子</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>GET</strong></td>
<td>查询</td>
<td><code>GET /student</code> 查学生列表</td>
</tr>
<tr>
<td><strong>POST</strong></td>
<td>新增</td>
<td><code>POST /student</code> 新增一个学生</td>
</tr>
<tr>
<td><strong>PUT</strong></td>
<td>修改</td>
<td><code>PUT /student/1</code> 修改 1 号学生</td>
</tr>
<tr>
<td><strong>DELETE</strong></td>
<td>删除</td>
<td><code>DELETE /student/1</code> 删除 1 号学生</td>
</tr>
</tbody>
</table>
<p>路径负责"对谁做"，方法负责"做什么"。这就是 RESTful 风格的核心思想。</p>
<h2>12. 路由映射</h2>
<p><strong>路由映射</strong>指的是：把客户端请求的 <strong>URL 路径</strong>，映射到服务器端相应的<strong>处理函数</strong>上。</p>
<p>当你访问 <code>/student/1</code> 时，服务器根据预先定义好的规则，找到负责处理这个路径的函数，执行它，再把返回值打包成响应发回来。</p>
<h2>13. 动手：第一个 FastAPI 程序</h2>
<p>前面铺垫了这么多，终于可以动手了。</p>
<p>:::steps[跑起你的第一个接口]</p>
<ol>
<li>
<p><strong>安装依赖</strong></p>
<p>FastAPI 是框架本体，uvicorn 是运行它的服务器（下一节解释为什么需要它）。</p>
<pre><code>pip install "fastapi[standard]" uvicorn
</code></pre>
</li>
<li>
<p><strong>写一个 <code>main.py</code></strong></p>
<pre><code>from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "你好，FastAPI"}
</code></pre>
</li>
<li>
<p><strong>启动服务</strong></p>
<pre><code>uvicorn main:app --reload
</code></pre>
<p>这行的意思是：启动 uvicorn，加载 <code>main.py</code> 里的 <code>app</code> 对象，并开启热重载（改代码自动重启）。</p>
</li>
<li>
<p><strong>打开文档页面</strong></p>
<p>浏览器访问 <code>http://127.0.0.1:8000/docs</code>：FastAPI 会根据你的代码<strong>自动生成交互式接口文档</strong>，可以直接在页面上点按钮发请求。
:::</p>
</li>
</ol>
<p>:::tip 为什么 FastAPI 会"自动"有这么好的文档？
因为它把你的<strong>类型注解</strong>当成了信息来源。你写 <code>student_id: int</code>，它既知道要校验成整数，也知道文档里该写"integer"。这是 FastAPI 最爽的地方之一。
:::</p>
<h2>14. uvicorn 是什么？ASGI 又是什么？</h2>
<p>上面我们装了 <code>uvicorn</code>，但没说它是干嘛的。</p>
<h3>14.1 服务器不只是"一台机器"</h3>
<p>先纠正一个概念：<strong>我们平时说的"Web服务器"，通常指的不是那台计算机，而是计算机上启动的 Web 应用程序。</strong></p>
<p>当你在本地跑 <code>uvicorn main:app</code>，你的电脑<strong>同时扮演了两个角色</strong>：它既是物理上的机器，也是运行着 Web 程序的服务器。</p>
<h3>14.2 WSGI 和 ASGI</h3>
<p>Web 框架和 Web 服务器之间需要一个"翻译官"，否则双方说不通话。Python 世界里有两套翻译规则：</p>
<table>
<thead>
<tr>
<th></th>
<th>WSGI</th>
<th>ASGI</th>
</tr>
</thead>
<tbody>
<tr>
<td>全称</td>
<td>Web Server Gateway Interface</td>
<td>Asynchronous Server Gateway Interface</td>
</tr>
<tr>
<td>同步/异步</td>
<td>同步</td>
<td>异步</td>
</tr>
<tr>
<td>代表框架</td>
<td>Flask、Django（传统模式）</td>
<td><strong>FastAPI</strong>、Starlette</td>
</tr>
<tr>
<td>代表服务器</td>
<td>Flask/Django 自带的开发服务器</td>
<td><strong>uvicorn</strong>、Daphne</td>
</tr>
<tr>
<td>支持 WebSocket</td>
<td>❌</td>
<td>✅</td>
</tr>
</tbody>
</table>
<p><strong>uvicorn 就是一个遵循 ASGI 规范的服务器。</strong></p>
<h3>14.3 为什么 Flask 不用装 ASGI 服务器，FastAPI 就要？</h3>
<p>这是很多人的困惑。答案是：<strong>Flask 是同步框架，用的是 WSGI，自带开发服务器；FastAPI 是异步框架，基于 ASGI，需要配一个 ASGI 服务器来跑。</strong></p>
<pre><code>flowchart LR
    C["客户端"] --&gt; U["uvicorn（ASGI 服务器）"]
    U --&gt; F["FastAPI 应用"]
    F --&gt; U --&gt; C
</code></pre>
<p>:::details 一定要用 ASGI 吗？简单页面用 WSGI 行不行？
对于<strong>简单的、同步的</strong>网页程序，用 WSGI 完全够了，Flask 和 Django 默认就是 WSGI，自带开发服务器，开箱即用。</p>
<p>但 WSGI 不支持异步特性（比如 WebSocket 这类实时通信）。如果你的应用需要<strong>处理高并发</strong>或者<strong>实时通信</strong>，ASGI 会是更好的选择。</p>
<p>所以：<strong>用什么，取决于你的需求</strong>，不是越新越好。
:::</p>
<h3>14.4 三者的分工</h3>
<p>三个角色各自负责的部分：</p>
<table>
<thead>
<tr>
<th>角色</th>
<th>它管什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>uvicorn</strong></td>
<td>解析 HTTP 报文（方法、路径、<code>Content-Length</code> 等），把请求交给应用，再把响应发回去</td>
</tr>
<tr>
<td><strong>FastAPI</strong></td>
<td>根据路由把请求分发到你的函数</td>
</tr>
<tr>
<td><strong>Pydantic</strong></td>
<td><strong>校验请求体的内容</strong>是否符合你定义的结构</td>
</tr>
</tbody>
</table>
<p>:::important 一个关键细节
<strong>uvicorn 只解析 HTTP 头部和基本的请求体数据，不会深入校验请求体的内容。</strong></p>
<p>即使客户端发来的数据结构是错的，uvicorn 依然会照单全收，把它交给 FastAPI，而<strong>不会主动拒绝</strong>。真正做校验的是 Pydantic。这就是为什么"数据校验"要由框架层来负责。
:::</p>
<h2>15. 路径参数</h2>
<p>现在如果我想通过 id 查询某个学生：</p>
<pre><code>@app.get("/students/1")
def get_student_1():
    return {"student_id": 1}
</code></pre>
<p>只有一个学生还行，<strong>一万个学生你总不能写一万个函数</strong>。</p>
<p>正确做法是：<strong>别把 <code>/students/</code> 后面的值定死</strong>，而是给它一个变量，再把这个变量套进函数参数里：</p>
<pre><code>@app.get("/students/{student_id}")
def get_student(student_id: int):
    return {"student_id": student_id}
</code></pre>
<p>现在 <code>/students/1</code>、<code>/students/999</code> 都会进同一个函数。类型注解 <code>int</code> 还会自动帮你校验，传 <code>/students/abc</code> 会直接返回 422 错误，而不是让你在函数里手动判断。</p>
<p>打开 <code>/docs</code> 你会发现，接口说明从"无参数"变成了带参数的输入框。</p>
<p>:::warning 路径参数遵从「顺序原则」
如果两个路由都能匹配同一个请求，FastAPI 会<strong>按注册顺序匹配，先匹配上的先执行</strong>。</p>
<p>所以像下面这样写，<code>/students/me</code> 永远走不到第二个函数：</p>
<pre><code>@app.get("/students/{student_id}")   # 先注册，先匹配
def get_student(student_id: str):
    return {"id": student_id}

@app.get("/students/me")             # 永远轮不到它
def get_me():
    return {"name": "我自己"}
</code></pre>
<p>把更具体的路由写在前面，是避免踩坑的好习惯。
:::</p>
<h2>16. 查询参数</h2>
<p>路径参数讲完了，现在轮到<strong>查询参数</strong>，也就是上一篇 URL 里 <code>?</code> 后面的那部分。</p>
<blockquote>
<p><strong>函数参数如果没出现在路径里，FastAPI 就默认把它当作查询参数。</strong></p>
</blockquote>
<pre><code>@app.get("/jobs")
def search_jobs(keyword: str, city: str, years: str):
    return {"keyword": keyword, "city": city, "years": years}
</code></pre>
<p>访问 <code>/jobs?keyword=python&amp;city=shanghai&amp;years=3-5</code> 就能拿到结果。</p>
<h3>16.1 让参数变成可选</h3>
<p>上面的写法有个问题：<strong>所有参数都必须传</strong>，少一个就报错。但现实中的招聘网站显然不是这样，你搜"python"，城市和工作经验明明是可填可不填的。</p>
<p>解决办法：<strong>给参数一个默认值 <code>None</code>。</strong></p>
<pre><code>from typing import Optional

@app.get("/jobs")
def search_jobs(
    keyword: str,
    city: Optional[str] = None,
    years: Optional[str] = None,
):
    return {"keyword": keyword, "city": city, "years": years}
</code></pre>
<p>:::tip 记住这条规则</p>
<ul>
<li><strong>没有默认值 → 必填</strong></li>
<li><strong>有默认值 → 选填</strong></li>
</ul>
<p>而且路径参数和查询参数是可以互相转换的：给参数加一对 <code>{}</code> 放进路径，它就成了路径参数；把 <code>{}</code> 去掉，它就变回查询参数。
:::</p>
<h2>17. 请求体与 Pydantic</h2>
<p>GET 用查询参数就够了，但 <strong>POST 要提交的数据比较复杂</strong>（比如一个完整的用户对象），这时候就要用<strong>请求体</strong>。</p>
<h3>17.1 在类里定义数据类型</h3>
<p>FastAPI 里，除了在函数上写类型注解，还可以<strong>用类来定义一整个数据结构</strong>。这个类就是"请求体的数据类型"。</p>
<pre><code>from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

@app.post("/users")
def create_user(user: User):
    return {"received": user}
</code></pre>
<h3>17.2 为什么 <code>User</code> 会自动变成请求体？</h3>
<p>这是新手最容易困惑的地方：我只是写了个 <code>user: User</code>，为什么它就成了请求体，还自动出现在 <code>/docs</code> 里？</p>
<pre><code>async def data(data: User):
</code></pre>
<p>括号里这部分有两个作用：</p>
<ol>
<li><strong>限制 <code>data</code> 的数据类型</strong>：因为 <code>User</code> 继承自 <code>BaseModel</code>，FastAPI 会用 Pydantic 自动校验传进来的数据格式</li>
<li><strong>自动识别为请求体</strong>：FastAPI 规定，<strong>参数类型是 <code>BaseModel</code> 的，默认就从请求体里取数据</strong>，你不需要额外声明</li>
</ol>
<p>:::note 参数的命名是自由的
<code>async def data(data: User)</code> 里的 <code>data</code> 只是变量名，你写成 <code>user</code>、<code>payload</code>、<code>body</code> 都行，<strong>重要的是类型注解那里的 <code>User</code></strong>。
:::</p>
<h3>17.3 Pydantic 是什么</h3>
<p><strong>Pydantic 就是负责检查客户端发来的数据是否符合标准的那一环。</strong> 想让它生效，类就必须继承 <code>BaseModel</code>。</p>
<p>客户端传错类型的处理流程是这样的：</p>
<pre><code>sequenceDiagram
    autonumber
    participant C as 客户端
    participant U as uvicorn
    participant F as FastAPI
    participant P as Pydantic
    C-&gt;&gt;U: POST /users 带数据
    U-&gt;&gt;F: 头部没问题，交给应用
    F-&gt;&gt;P: 按 User 模型校验请求体
    alt 校验通过
        P--&gt;&gt;F: 返回校验后的对象
        F--&gt;&gt;C: 200 + 处理结果
    else 校验失败
        P--&gt;&gt;F: 抛出校验错误
        F--&gt;&gt;C: 422 + 具体的错误原因
    end
</code></pre>
<h2>18. 数据校验：Field 与 validator</h2>
<p>光有类型还不够。比如年龄，我们不希望它是 <code>-5</code> 或者 <code>9999</code>。</p>
<h3>18.1 用 Field 加约束</h3>
<pre><code>from pydantic import BaseModel, Field

class User(BaseModel):
    name: str = Field(min_length=2, max_length=20, pattern=r"^[a-zA-Z]")
    age: int = Field(default=0, ge=0, le=100)
</code></pre>
<ul>
<li><code>ge=0</code> / <code>le=100</code>：大于等于 0，小于等于 100（不在范围内直接报错）</li>
<li><code>pattern</code>：正则约束，这里要求 name 必须以字母开头</li>
<li><code>default=0</code>：默认值</li>
</ul>
<p>:::tip Pydantic 版本差异
网上很多教程写的是 <code>regex=</code>，那是 <strong>Pydantic v1</strong> 的写法；<strong>v2 已经改成 <code>pattern=</code></strong>。如果你照着老教程写报错，先确认一下自己的版本。
:::</p>
<h3>18.2 用 validator 写自定义校验</h3>
<p>如果要校验的规则比较复杂（比如"必须全是字母"），就用校验器：</p>
<pre><code>from pydantic import BaseModel, field_validator

class User(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def name_must_be_alpha(cls, value: str) -&gt; str:
        assert value.isalpha(), "name must be alpha"
        return value
</code></pre>
<ul>
<li><code>cls</code> 代表类对象本身</li>
<li><code>value</code> 是当前要校验的值，你输入什么，它就是什么</li>
<li><code>assert</code> 用来判断是否符合要求：通过就继续往下走，不通过就抛出后面那句话作为错误信息</li>
</ul>
<p>:::note v1 的写法
Pydantic v1 用的是 <code>@validator("name")</code>，v2 改成了 <code>@field_validator("name")</code> 并且需要配合 <code>@classmethod</code>。逻辑是一样的。
:::</p>
<h2>19. 模型的嵌套</h2>
<p>真实的数据往往是嵌套的，一个用户里还带着一个地址：</p>
<pre><code>from pydantic import BaseModel

class Address(BaseModel):
    city: str
    street: str

class User(BaseModel):
    name: str
    age: int
    address: Address      # 直接引用另一个模型
</code></pre>
<p>FastAPI 会自动递归校验：<code>address</code> 里少一个字段，整个请求就会被拒绝，并明确告诉你是哪个字段出了问题。</p>
<h2>20. async / await</h2>
<p>FastAPI 对异步支持得非常好，所以你会在官方示例里频繁看到 <code>async def</code>：</p>
<pre><code>@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}
</code></pre>
<h3>20.1 为什么要异步</h3>
<p><strong>I/O 操作</strong>指的是程序与外部世界交换数据的过程：读文件、发网络请求、查数据库等等。这类操作的特点是要<strong>等</strong>。</p>
<p>同步编程里，程序发出 I/O 请求后只能<strong>干等着</strong>。而异步编程里，程序发起 I/O 后<strong>不必等它完成</strong>，可以先去干别的事，等结果回来了再处理。</p>
<p>对于 Web 服务器来说，这意味着<strong>一个请求在等数据库返回的时候，服务器可以去处理别的请求</strong>，并发能力因此大幅提升。</p>
<h3>20.2 async 和 await</h3>
<table>
<thead>
<tr>
<th>关键字</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>async</code></td>
<td>定义一个异步函数（协程）</td>
</tr>
<tr>
<td><code>await</code></td>
<td>在协程内部调用其他异步函数；表示"这里可能要等，但不会阻塞整个程序"</td>
</tr>
</tbody>
</table>
<pre><code>import httpx

@app.get("/proxy")
async def proxy():
    async with httpx.AsyncClient() as client:
        resp = await client.get("https://example.com")   # 等待期间，服务器能去处理别的请求
    return {"status": resp.status_code}
</code></pre>
<p>:::caution 不要为了 async 而 async
如果一个函数内部<strong>根本没有异步操作</strong>（没有 <code>await</code>），那加 <code>async</code> 只会引入额外开销，反而更慢。</p>
<p><strong>判断依据是函数里有没有需要 <code>await</code> 的 I/O 操作。有就异步，没有就老老实实写 <code>def</code>。</strong>
:::</p>
<hr />
<h2>21. 分层对照</h2>
<table>
<thead>
<tr>
<th>层次</th>
<th>你学到的东西</th>
</tr>
</thead>
<tbody>
<tr>
<td>最底层</td>
<td><strong>协议</strong>是双方对格式的约定；<strong>Socket</strong> 是把传输层以下全部封装起来的一根管子</td>
</tr>
<tr>
<td>中间层</td>
<td><strong>TCP/IP 四层</strong>；HTTP 建立在 TCP 之上；<strong>HTTP 有四个特性</strong>（基于 TCP/IP、请求-响应、无状态、长短连接）</td>
</tr>
<tr>
<td>报文层</td>
<td><strong>请求报文</strong> = 首行 + 请求头 + 空行 + 请求体；<strong>响应报文</strong> = 状态行 + 响应头 + 空行 + 响应体</td>
</tr>
<tr>
<td>设计层</td>
<td><strong>API</strong> 与前后端分离；<strong>RESTful</strong> 用 HTTP 方法表达动作</td>
</tr>
<tr>
<td>框架层</td>
<td><strong>FastAPI</strong> + <strong>uvicorn</strong> + <strong>Pydantic</strong> 三者分工；路径参数、查询参数、请求体、数据校验</td>
</tr>
</tbody>
</table>
<h3>下一步可以玩什么</h3>
<ul>
<li>把上一篇的爬虫改成 FastAPI 服务：写一个 <code>/api/novel/{chapter}</code> 接口，让浏览器也能拿到数据</li>
<li>学一下 <strong>依赖注入</strong>（<code>Depends</code>），它是 FastAPI 处理登录鉴权、数据库连接的标准姿势</li>
<li>用 <code>APIRouter</code> 把接口拆成多个文件，再用 <code>include_router</code> 组装起来（大项目必学）</li>
</ul>
<p>:::tip 从"能跑"到"懂原理"
上一篇我们追求的是<strong>快速见效</strong>，先让代码跑起来，尝到甜头。</p>
<p>这一篇补的是<strong>底子</strong>：协议、Socket、报文格式。这些东西平时写业务代码时不会天天想，但一旦遇到"请求为什么被拒""返回为什么是乱码""参数为什么校验不过"，它们就是你去排查问题的地图。</p>
<p>先跑起来，再搞明白。顺序别反了，也别停在前一步。
:::</p>
<p>如果你跟着敲完了，或者卡在某个环节，欢迎来<a href="/about/">关于页</a>找我聊。上一篇在这里：<a href="/posts/python-crawler-quickstart/">Python 爬虫入门：写一个能跑起来的小说爬虫</a>。</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>第三章　华为云 IoTDA 物联网入门-北向</title>
    <link href="https://cnyicheng.top/posts/huawei-iotda-harmonyos-northbound/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/huawei-iotda-harmonyos-northbound/</id>
    <published>2026-09-17T00:00:00.000Z</published>
    <updated>2026-09-17T00:00:00.000Z</updated>
    <summary>把设备、云平台、App 三者的关系画清楚，讲透 MQTT 与物联网，再落到鸿蒙北向 App 该怎么调 API 控制硬件。</summary>
    <content type="html"><![CDATA[<p>这章来讲讲鸿蒙北向吧
如果你是学<strong>鸿蒙北向</strong>的同学，多半会遇到这样的需求：</p>
<blockquote>
<p>"我们南向的设备把心率数据传到云上了，你做个 App 把数据显示出来，再做个按钮能控制风扇开关。"</p>
</blockquote>
<p>这一章就是为解决此问题而出的教程</p>
<p>:::note 什么是北向、南向</p>
<ul>
<li><strong>南向</strong>：设备侧。硬件怎么连上网、怎么把数据传到云平台。通常由硬件/嵌入式同学负责</li>
<li><strong>北向</strong>：应用侧。App、网页、上位机怎么调云平台的接口，读数据、下命令。</li>
</ul>
<p>这一章会先带你走一遍南向的流程（因为不理解它，你就不知道数据长什么样，在哪，怎么上云的，怎么拉），但重点落在北向。
:::</p>
<hr />
<h1>第一部分　</h1>
<p>物联网项目里其实有三条链路，初学最容易在脑子里搅成一团：</p>
<pre><code>flowchart LR
    subgraph S["南向：设备侧"]
        D["① 硬件设备传感器 / 单片机"]
    end
    subgraph C["云：华为云 IoTDA"]
        P["② 物联网平台"]
    end
    subgraph N["北向：应用侧"]
        A["③ 手机 App鸿蒙 / 网页 / 上位机"]
    end
    D --&gt;|"MQTT 上报数据"| P
    P --&gt;|"MQTT 下发命令"| D
    A --&gt;|"HTTPS 调 API 读数据"| P
    A --&gt;|"HTTPS 调 API 下发命令"| P
</code></pre>
<p>三条链路的分工：</p>
<table>
<thead>
<tr>
<th>链路</th>
<th>谁到谁</th>
<th>用什么协议</th>
<th>谁负责</th>
</tr>
</thead>
<tbody>
<tr>
<td>① → ②</td>
<td>设备 → 云</td>
<td><strong>MQTT</strong></td>
<td>硬件同学</td>
</tr>
<tr>
<td>② → ①</td>
<td>云 → 设备</td>
<td><strong>MQTT</strong>（下行）</td>
<td>硬件同学</td>
</tr>
<tr>
<td>③ ↔ ②</td>
<td>App ↔ 云</td>
<td><strong>HTTPS / REST API</strong></td>
<td><strong>你</strong></td>
</tr>
</tbody>
</table>
<p><strong>设备用 MQTT 跟云说话，App 用 HTTPS 跟云说话。</strong></p>
<p>设备走的是 MQTT，你走的是 HTTP 那一套，所以第二章学的 HTTP 请求、请求头、JSON、<code>Content-Type</code>，在这里<strong>全都能直接用上</strong>。
:::</p>
<h3>1.1 为什么设备用 MQTT，而不用 HTTP？</h3>
<p>你可能会问：既然 HTTP 这么好用，为什么设备不也用 HTTP？</p>
<pre><code>sequenceDiagram
    autonumber
    participant D as 设备
    participant C as 云平台
    Note over D,C: HTTP：设备必须主动问
    D-&gt;&gt;C: 有没有给我的命令？
    C--&gt;&gt;D: 没有
    D-&gt;&gt;C: 有没有给我的命令？
    C--&gt;&gt;D: 没有
    Note over D,C: MQTT：云可以直接推
    D-&gt;&gt;C: 建立长连接 + 订阅我的主题
    C--&gt;&gt;D: 有命令了，推给你！
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>HTTP</th>
<th>MQTT</th>
</tr>
</thead>
<tbody>
<tr>
<td>通信模式</td>
<td>请求-响应（<strong>只能客户端先发起</strong>）</td>
<td>发布-订阅（<strong>双方都能主动推</strong>）</td>
</tr>
<tr>
<td>连接</td>
<td>短连接，用完就断</td>
<td>长连接，一直挂着</td>
</tr>
<tr>
<td>开销</td>
<td>头部信息大</td>
<td>头部极小（最小只要 2 字节）</td>
</tr>
<tr>
<td>适合</td>
<td>网页、调 API</td>
<td>设备、传感器、弱网、省电场景</td>
</tr>
</tbody>
</table>
<p>回想第二章讲的"HTTP 是无状态的、服务器不能主动给你发消息"，<strong>这正是设备场景不能忍的地方</strong>。</p>
<p>一台体温计不可能每隔几秒就跑去问云"有没有新命令"（那又费电又费流量）。它需要的是：连上就别断，云那边一有命令，立刻推过来。这就是 MQTT 的<strong>发布-订阅</strong>模型。</p>
<p>:::details MQTT 的核心概念：主题（Topic）
MQTT 不靠"你问我答"，而是靠<strong>主题</strong>：</p>
<ul>
<li>设备<strong>订阅</strong>某个主题，就相当于"我盯着这个频道的消息"</li>
<li>谁<strong>发布</strong>到这个主题，订阅者就会收到</li>
</ul>
<p>比如设备订阅了 <code>$oc/devices/{设备ID}/sys/commands/#</code>，那么云平台往这个主题发布一条命令，设备立刻就能收到。</p>
<p><code>#</code> 是通配符，表示"这个目录下的所有子主题"。
:::</p>
<h2>2. 云平台在中间干了什么</h2>
<p>华为云 IoTDA（设备接入服务）扮演的是<strong>中间人 + 账本</strong>的角色：</p>
<ul>
<li><strong>设备管理</strong>：谁注册过、在不在线、是什么型号</li>
<li><strong>消息转发</strong>：把设备上报的数据收下来</li>
<li><strong>数据存储</strong>：把设备最新状态存成"设备影子"，<strong>这就是你待会儿要读的东西</strong></li>
<li><strong>命令下发</strong>：把你调的 API 请求，转成 MQTT 消息推给设备</li>
</ul>
<p>:::important 记住「设备影子」这个词
<strong>设备影子（Device Shadow）= 云平台上保存的设备最新状态快照，其实管你这了那了的，你就当设备影子就是设备数据。</strong></p>
<p>设备上报一次，云上就更新一次。App 想知道设备现在怎么样，<strong>不用直接连设备</strong>，只要读影子就行。</p>
<p>这解决了两个大问题：① 设备可能不在线，但影子还在；② App 不用关心设备用什么协议，读一个 HTTPS 接口就够了。
:::</p>
<hr />
<h1>第二部分　南向：设备是怎么接进来的</h1>
<p>这部分内容<strong>平时是硬件同学在做</strong>，但你必须看懂，因为你要拿的就是这些配置信息。</p>
<h2>3. 在 IoTDA 控制台上做四件事</h2>
<p>:::steps[设备侧的四步]</p>
<ol>
<li>
<p><strong>创建产品</strong></p>
<p>产品 = 一类设备的"型号定义"。比如"智慧养老手环"就是一个产品。</p>
</li>
<li>
<p><strong>创建服务</strong></p>
<p>服务 = 一个产品下面的一组功能集合。你可以先粗略理解成"一个大产品里包含的某个小硬件"。</p>
<p>这一步基本上只需要你填一个<strong>服务 ID</strong>，记住它，后面会反复用到。</p>
</li>
<li>
<p><strong>创建属性</strong></p>
<p>属性 = 具体的数据项，比如<strong>心率、血氧、体温</strong>。</p>
<p>创建时要填名称和<strong>数据类型</strong>（整数、小数、字符串、布尔……），名称就是数据名。</p>
</li>
<li>
<p><strong>注册设备</strong></p>
<p>设备 = 这个产品下的一台<strong>具体</strong>硬件。设备标识码和密钥可以自己填，注册完成后平台会给你一份信息，里面有<strong>设备 ID 和密钥</strong>：这两个东西<strong>千万别弄丢，也千万别外传</strong>。
:::</p>
</li>
</ol>
<h3>3.1 属性到底要不要建？</h3>
<p>有个常见的纠结：<strong>我到底该不该建属性？</strong></p>
<p>按数据流向分两种情况：</p>
<ul>
<li><strong>需要把数据实时读到 App 里</strong> → 建属性，让设备上报</li>
<li><strong>只是 App 往设备下发命令</strong>（比如开关风扇），不需要回传数据 → 可以不建属性</li>
</ul>
<p>:::tip 比赛建议
如果要参赛，<strong>建议多做一些互动元素</strong>。比如风扇开关，除了下发开关命令，还可以专门建一个属性用来回传"风扇现在的工作状态"，这样 App 上就能显示实时状态，而不是只有一个盲操作的按钮。
:::</p>
<h2>4. MQTT 三元组：设备登录云的"账号密码"</h2>
<p>设备要连上云，需要三个东西，俗称 <strong>MQTT 三元组</strong>：</p>
<table>
<thead>
<tr>
<th>名称</th>
<th>作用</th>
<th>从哪来</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ClientId</strong>（客户端 ID）</td>
<td>我是哪台设备</td>
<td>由工具根据设备 ID 生成</td>
</tr>
<tr>
<td><strong>Username</strong>（用户名）</td>
<td>我属于哪个设备</td>
<td>通常是 <code>{设备ID}</code></td>
</tr>
<tr>
<td><strong>Password</strong>（密码）</td>
<td>证明我真的是那台设备</td>
<td>由密钥签名算出</td>
</tr>
</tbody>
</table>
<p>华为云提供了一个在线工具，把设备 ID 和密钥填进去，<strong>三元组自动生成</strong>：</p>
<pre><code>https://iot-tool.obs-website.cn-north-4.myhuaweicloud.com/
</code></pre>
<p>:::warning 一个关键设置
工具里有「<strong>签名类型</strong>」这一项，选 <strong>「不校验时间戳」</strong>。</p>
<p>为什么？如果校验时间戳，生成的密码会跟时间绑定，过一段时间就失效，设备就得不停地重新生成。选不校验，这个密码就是<strong>长期有效</strong>的，方便调试。</p>
<p>代价是：<strong>这个密码等于永久凭证，泄露了就等于设备被人接管了。</strong> 所以绝对不能提交到 Git、不能贴在公开博客里（下面会专门讲）。
:::</p>
<p>生成出来的东西大概长这样（<strong>下面是占位符，不是真实值</strong>）：</p>
<pre><code>ClientId：{设备ID}_0_0_{时间戳}
Username：{设备ID}
Password：{根据密钥算出的签名}
</code></pre>
<hr />
<h1>第三部分　没有硬件，怎么先跑起来</h1>
<p>手头没有硬件怎么办？<strong>用 MQTT 客户端软件模拟一台设备。</strong> 这样你可以先把整条链路跑通，等硬件到了直接替换。</p>
<p>推荐用 <strong>MQTTX</strong>（跨平台，界面清爽）。</p>
<h2>5. 连接信息从哪找</h2>
<p>在 IoTDA 控制台的设备详情页里，能找到<strong>设备接入地址</strong>：</p>
<table>
<thead>
<tr>
<th>配置项</th>
<th>值</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>服务器地址</strong></td>
<td><code>{你的设备接入域名}</code>（形如 <code>xxxxxx.st1.iotda-device.cn-north-4.myhuaweicloud.com</code>）</td>
</tr>
<tr>
<td><strong>端口</strong></td>
<td><code>1883</code>（非加密）/ <code>8883</code>（TLS 加密）—— 端口号是固定的</td>
</tr>
<tr>
<td>ClientId / Username / Password</td>
<td>上一节生成的三元组</td>
</tr>
</tbody>
</table>
<p>:::tip 连不上先 ping 一下
如果连接失败，先用域名 <code>ping</code> 一下，确认网络能通；再用域名解析工具确认域名能解析成 IP。</p>
<p>连不通通常就三类原因：<strong>地址填错、三元组填错、公司/学校网络把端口拦了</strong>。
:::</p>
<h2>6. 发布和订阅</h2>
<p>连上之后，就要用**主题（Topic）**收发数据了。IoTDA 的主题格式是固定的，你只需要把 <code>{设备ID}</code> 换成自己的：</p>
<table>
<thead>
<tr>
<th>你要干什么</th>
<th>主题</th>
<th>动作</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>上报属性</strong>（设备 → 云）</td>
<td><code>$oc/devices/{设备ID}/sys/properties/report</code></td>
<td>发布（Publish）</td>
</tr>
<tr>
<td><strong>订阅命令</strong>（云 → 设备）</td>
<td><code>$oc/devices/{设备ID}/sys/commands/#</code></td>
<td>订阅（Subscribe）</td>
</tr>
<tr>
<td><strong>订阅属性设置</strong>（云 → 设备）</td>
<td><code>$oc/devices/{设备ID}/sys/properties/set/#</code></td>
<td>订阅（Subscribe）</td>
</tr>
</tbody>
</table>
<h3>6.1 上报的数据长什么样</h3>
<p>上报属性的内容就是 <strong>JSON</strong>，格式是固定的两层结构：<code>services</code> 数组 → 里面是 <code>service_id</code> 和 <code>properties</code>：</p>
<pre><code>{
  "services": [
    {
      "service_id": "health",
      "properties": {
        "heart_rate": 78,
        "spo2": 98,
        "temperature": 36.5
      }
    }
  ]
}
</code></pre>
<p>:::note 几个容易踩的坑</p>
<ul>
<li><code>service_id</code> 必须和你在控制台建服务时填的<strong>完全一致</strong>（大小写敏感）</li>
<li><code>properties</code> 里的字段名必须和建属性时的<strong>名称一模一样</strong></li>
<li>类型要对得上，属性建的是整数，你传了字符串就会报错
:::</li>
</ul>
<h3>6.2 完整的通信流程</h3>
<pre><code>sequenceDiagram
    autonumber
    participant D as 设备（或 MQTTX）
    participant C as 华为云 IoTDA
    participant A as 你的 App
    D-&gt;&gt;C: ① 用三元组建立 MQTT 长连接
    D-&gt;&gt;C: ② 订阅命令主题
    D-&gt;&gt;C: ③ 发布属性（心率/血氧/体温）
    C-&gt;&gt;C: ④ 更新设备影子
    A-&gt;&gt;C: ⑤ 调 API 读设备影子（HTTPS）
    C--&gt;&gt;A: ⑥ 返回 JSON（含 reported 数据）
    A-&gt;&gt;C: ⑦ 调 API 修改设备属性（下发命令）
    C--&gt;&gt;D: ⑧ MQTT 推送命令到设备
    D--&gt;&gt;A: （可选）设备上报新状态，App 再读一次影子
</code></pre>
<p>:::tip 到这里，南向的部分就结束了
剩下的事情（把数据真正显示出来、按钮点了能下发命令）<strong>全是你的活儿</strong>。下面进入北向。
:::</p>
<hr />
<h1>第四部分　北向：App 要调哪些接口</h1>
<p>动手写代码之前，有三个问题绕不开：</p>
<ol>
<li>调用什么 API 接口<strong>获取</strong>设备上传的数据？</li>
<li>调用什么 API 接口<strong>下发命令</strong>给设备？</li>
<li>调用这些接口<strong>需要什么参数</strong>？</li>
</ol>
<h2>7. 先拿凭证：IAM Token</h2>
<p>App 要调华为云的接口，得先证明"我有权限"。这个凭证就是 <strong>Token</strong>。</p>
<p>:::note Token 是什么
<strong>Token 是 App 访问服务器接口的通信凭证</strong>，相当于一张临时通行证。</p>
<p>华为云要求你调任何业务接口时，都在请求头里带上 <code>X-Auth-Token: {你的token}</code>。没有它，一律 401。
:::</p>
<h3>7.1 准备好这几样东西</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>IAM 账号 / 密码</strong></td>
<td>你创建的子账号和密码</td>
</tr>
<tr>
<td><strong>主账号名称</strong></td>
<td>企业管理员名称（华为云账号名）</td>
</tr>
<tr>
<td><strong>AK / SK</strong></td>
<td>Access Key / Secret Key，程序调用时用的密钥对</td>
</tr>
<tr>
<td><strong>项目 ID</strong></td>
<td>Project ID，标识你的资源在哪个项目下</td>
</tr>
</tbody>
</table>
<p><strong>项目 ID 在哪找</strong>：控制台右上角 → <strong>我的凭证</strong> → 项目列表 → 点进你在做的项目，就能看到<strong>项目 ID</strong> 和<strong>所属区域</strong>。</p>
<p><strong>AK/SK 怎么拿</strong>：找到右上角<strong>统一身份认证</strong>，创建一个子账号，创建并配置完成后就会得到 <code>user name</code>（IAM 账号名）、<strong>AK 和 SK</strong>。</p>
<p>:::warning 关于 AK/SK
AK/SK 的权限非常大，<strong>一旦泄露等于账号被人拿走</strong>。</p>
<p>所以 <strong>AK/SK 只放在服务端</strong>，或者只放在你自己机器上的环境变量里，绝对不要写进要发布的 App、也不要提交到 Git。
:::</p>
<h3>7.2 用 API Explorer 生成 Token</h3>
<p>华为云的 <strong>API Explorer</strong> 可以在线帮你拼请求、直接调试、还会<strong>自动生成 SDK 代码</strong>，这是北向开发最省事的入口。</p>
<pre><code>https://apiexplorer.developer.huaweicloud.com/
</code></pre>
<p>按顺序把参数填进去，遇到不确定的字段就用默认生成的，<strong>只要最后右边的「生成 Token」是成功的就说明配对了</strong>。</p>
<p>Token 有有效期，通常是 24 小时，过期就会返回 <code>401</code>。所以 App 里要处理"token 失效就重新获取"这件事。</p>
<h2>8. 读数据：查询设备影子</h2>
<p><strong>这是回答"调用什么接口获取设备上传数据"的答案。</strong></p>
<table>
<thead>
<tr>
<th>项目</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td>接口名</td>
<td><strong>查询设备影子数据（ShowDeviceShadow）</strong></td>
</tr>
<tr>
<td>方法</td>
<td><code>GET</code></td>
</tr>
<tr>
<td>文档</td>
<td>API Explorer 搜 <code>ShowDeviceShadow</code></td>
</tr>
</tbody>
</table>
<p>请求地址的格式是：</p>
<pre><code>GET https://{应用侧接入点}/v5/iot/{项目ID}/devices/{设备ID}/shadow
Header: X-Auth-Token: {你的 Token}
</code></pre>
<p>:::tip 怎么验证通了没
如果之前用硬件或 MQTT 客户端给服务器传过数据，那么接口响应体里的 <strong><code>reported</code></strong> 字段里就能看到你上报的参数。</p>
<p>比如你上报过 <code>heart_rate</code>，响应里就会出现：</p>
<pre><code>{
  "shadow": [
    {
      "service_id": "health",
      "reported": {
        "properties": { "heart_rate": 78, "spo2": 98 },
        "event_time": "20260917T120000Z"
      }
    }
  ]
}
</code></pre>
<p><strong><code>reported</code> 就是设备上报的数据</strong>，<code>desired</code> 则是你期望设备达到的状态。这两个词记住，调接口时不会再看花眼。
:::</p>
<h2>9. 下命令：两种方式</h2>
<p>回答第二个问题，<strong>怎么让 App 控制硬件</strong>。IoTDA 提供两条路：</p>
<table>
<thead>
<tr>
<th>方式</th>
<th>特点</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>法一：下发命令</strong>（同步命令）</td>
<td>语义明确，适合"立刻执行一次"的动作</td>
</tr>
<tr>
<td><strong>法二：修改设备属性</strong></td>
<td>语义是"把设备改成这个状态"，更贴近物联网场景</td>
</tr>
</tbody>
</table>
<p><strong>两者除了接口不同，功能上没有本质区别。</strong> 下面用**法二（修改设备属性）**来演示，因为它跟前面的"属性"概念是连贯的。</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td>接口名</td>
<td><strong>修改设备属性（UpdateProperties）</strong></td>
</tr>
<tr>
<td>方法</td>
<td><code>PUT</code></td>
</tr>
<tr>
<td>地址</td>
<td><code>https://{应用侧接入点}/v5/iot/{项目ID}/devices/{设备ID}/properties</code></td>
</tr>
</tbody>
</table>
<p>:::steps[下发命令的三个前提]</p>
<ol>
<li>
<p><strong>保证设备在线</strong></p>
<p>设备离线的话，命令发不出去。先在控制台确认设备状态是"在线"。</p>
</li>
<li>
<p><strong>填写数据</strong></p>
<p>想改哪个属性就写哪个，注意类型和名字要和建属性时一致。</p>
</li>
<li>
<p><strong>构造并调试请求体</strong></p>
<p>请求体是 JSON，格式必须自己写对，写完先在 API Explorer 里调试通过。
:::</p>
</li>
</ol>
<p>请求体长这样：</p>
<pre><code>{
  "services": [
    {
      "service_id": "health",
      "properties": {
        "fan_switch": true
      }
    }
  ]
}
</code></pre>
<p>调试成功后，服务器就会把这条指令通过 MQTT <strong>推给设备</strong>。设备收到 <code>true</code>，代码里做个字符串判断，就可以打开风扇。</p>
<p>:::caution 少一步都会失败
<strong>设备在线 + 数据格式对 + 请求体 JSON 正确</strong>，三样缺一不可。新手最常卡在第二、三步。</p>
<p>排查顺序建议：先确认设备在线 → 再用 API Explorer 手动调一次 → 通了再写进代码。
:::</p>
<hr />
<h1>第五部分　落到鸿蒙：北向 App 怎么写</h1>
<p>前面都是"看懂"，现在讲"写出来"。</p>
<h2>10. 整体思路：四步走</h2>
<pre><code>flowchart LR
    A["① 拿 TokenPOST IAM 接口"] --&gt; B["② 读影子GET shadow"]
    B --&gt; C["③ 显示到界面ArkTS UI"]
    C --&gt; D["④ 下发命令PUT properties"]
    D --&gt; B
</code></pre>
<h2>11. ArkTS 里怎么发 HTTPS 请求</h2>
<p>鸿蒙应用侧用 <code>@kit.NetworkKit</code> 的 <code>http</code> 模块就能发 HTTPS 请求。</p>
<p>:::warning 先加权限
别忘了在 <code>module.json5</code> 里声明网络权限，否则请求会直接失败：</p>
<pre><code>{
  "requestPermissions": [
    { "name": "ohos.permission.INTERNET" }
  ]
}
</code></pre>
<p>:::</p>
<h3>11.1 拿 Token</h3>
<pre><code>import { http } from '@kit.NetworkKit';

const IAM_ENDPOINT = 'https://iam.cn-north-4.myhuaweicloud.com';
const PROJECT_NAME = 'cn-north-4';

export async function getToken(account: string, password: string, domain: string): Promise&lt;string&gt; {
  const req = http.createHttp();
  try {
    const resp = await req.request(`${IAM_ENDPOINT}/v3/auth/tokens`, {
      method: http.RequestMethod.POST,
      header: { 'Content-Type': 'application/json' },
      extraData: JSON.stringify({
        auth: {
          identity: {
            methods: ['password'],
            password: {
              user: { name: account, password, domain: { name: domain } }
            }
          },
          scope: { project: { name: PROJECT_NAME } }
        }
      })
    });

    // Token 不在 body 里，而是在响应头 X-Subject-Token 中
    return resp.header['X-Subject-Token'] as string;
  } finally {
    req.destroy();
  }
}
</code></pre>
<p>:::note 最容易栽的一个坑
Token <strong>不在响应体里</strong>，而是在<strong>响应头</strong> <code>X-Subject-Token</code> 里返回。</p>
<p>很多人一直去读 <code>resp.result</code>，然后发现是空的，就是这个原因。
:::</p>
<h3>11.2 读设备影子</h3>
<pre><code>const APP_ENDPOINT = '{你的应用侧接入点}';
const PROJECT_ID = '{你的项目 ID}';
const DEVICE_ID = '{你的设备 ID}';

export async function getShadow(token: string): Promise&lt;ShadowResp&gt; {
  const req = http.createHttp();
  try {
    const resp = await req.request(
      `https://${APP_ENDPOINT}/v5/iot/${PROJECT_ID}/devices/${DEVICE_ID}/shadow`,
      {
        method: http.RequestMethod.GET,
        header: { 'X-Auth-Token': token }
      }
    );
    return JSON.parse(resp.result as string) as ShadowResp;
  } finally {
    req.destroy();
  }
}
</code></pre>
<h3>11.3 下发命令（改属性）</h3>
<pre><code>export async function setFan(token: string, on: boolean) {
  const req = http.createHttp();
  try {
    return await req.request(
      `https://${APP_ENDPOINT}/v5/iot/${PROJECT_ID}/devices/${DEVICE_ID}/properties`,
      {
        method: http.RequestMethod.PUT,
        header: {
          'X-Auth-Token': token,
          'Content-Type': 'application/json'   // 别忘了它，服务器才知道怎么解包
        },
        extraData: JSON.stringify({
          services: [{ service_id: 'health', properties: { fan_switch: on } }]
        })
      }
    );
  } finally {
    req.destroy();
  }
}
</code></pre>
<p>:::tip 呼应第二章
注意 <code>Content-Type: application/json</code>：这不就是第二章讲的那个"告诉服务器怎么解包"的头吗？</p>
<p>还有 <code>X-Auth-Token</code>，它和第一章爬虫里伪装的 <code>User-Agent</code> 是同一类东西：<strong>都是请求头，都是给对方看的说明卡片</strong>。
:::</p>
<h2>12. 写在最后：几个能让作品加分的地方</h2>
<ol>
<li>
<p><strong>别把凭证写死在代码里</strong>
Token 有 24 小时有效期，AK/SK 更是永久凭证。比赛演示可以在本地配置，<strong>但提交的代码里一定要留占位符</strong>。</p>
</li>
<li>
<p><strong>给状态加个"刷新"和"自动轮询"</strong>
设备状态会变，界面得能跟着变。最简单的做法是定时轮询影子接口（比如 3~5 秒一次），进阶可以用 WebSocket。</p>
</li>
<li>
<p><strong>命令下发要有反馈</strong>
按钮点下去之后，别只是"发了请求"。要提示"发送中 / 成功 / 失败"，最好再读一次影子确认状态真的变了。</p>
</li>
<li>
<p><strong>UI 是评委最直观的感受</strong>
数据卡片、状态色块、开关动画，这些"面子工程"在比赛里真的很重要，而且这正是<strong>鸿蒙北向同学的核心优势</strong>。</p>
</li>
</ol>
<hr />
<h2>13. 三条链路对照</h2>
<table>
<thead>
<tr>
<th>环节</th>
<th>谁做</th>
<th>用什么</th>
<th>你要关心的</th>
</tr>
</thead>
<tbody>
<tr>
<td>设备连上云</td>
<td>硬件同学</td>
<td>MQTT 三元组</td>
<td>知道设备 ID 就行</td>
</tr>
<tr>
<td>设备上报数据</td>
<td>硬件同学</td>
<td>MQTT 发布到 <code>properties/report</code></td>
<td>知道服务 ID、属性名</td>
</tr>
<tr>
<td>云保存状态</td>
<td>华为云 IoTDA</td>
<td>设备影子</td>
<td>记住 <code>reported</code> / <code>desired</code></td>
</tr>
<tr>
<td>App 读数据</td>
<td><strong>你</strong></td>
<td>HTTPS <code>GET .../shadow</code></td>
<td>带 Token</td>
</tr>
<tr>
<td>App 下命令</td>
<td><strong>你</strong></td>
<td>HTTPS <code>PUT .../properties</code></td>
<td>设备在线 + JSON 正确</td>
</tr>
</tbody>
</table>
<p>:::important 北向开发的本质
<strong>北向开发本质上就是"带 Token 调 REST 接口"。</strong></p>
<p>前面两章学的 HTTP 请求、请求头、JSON、状态码，在这里一样都不用改。唯一的区别是：数据不是网页，而是设备的心跳。
:::</p>
<hr />
<p>最后如果你在开发需要好用的API接口，你可以看看你想接入的任何产品接口文档，还有华为云自己的华为开发者联盟</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>Python爬虫入门推荐：简单的脚本可以快速建立学习兴趣</title>
    <link href="https://cnyicheng.top/posts/python-crawler-quickstart/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/python-crawler-quickstart/</id>
    <published>2026-09-17T00:00:00.000Z</published>
    <updated>2026-09-17T00:00:00.000Z</updated>
    <summary>写给刚学完 Python基础语法、但是不知道这门语言有什么用，想立刻看到成果的新生，爬些感兴趣的东西可能是让你进入脚本世界的最好选择：从浏览器 DevTools 找线索，用 requests + lxml 一步步抓下整本小说。</summary>
    <content type="html"><![CDATA[<p>刚学完 Python 语法，会写 <code>for</code>、会写函数、知道什么是字典，然后呢？</p>
<p>很多人卡在这一步：语法都懂，却不知道手里这门语言能干什么。这篇文章给你一个<strong>半小时内就能亲手跑出结果</strong>的小项目，用 Python 抓一本小说，存成 txt。</p>
<p>不需要框架，如果你不看什么乱七八糟的教程（比如余胜军），也不用一上来就啃各种底层。你只要会两件事：<strong>懵懂的发一个 HTTP 请求</strong>，<strong>感受一下，从返回的 HTML 里把想要的文字挑出来</strong>。</p>
<p>:::note 这篇文章的目标
读完你应该能做到：给定一个小说章节页，让代码从第一章自动抓到最后一章，最后输出一个完整的 txt 文件。
:::</p>
<h2>0. 准备：装两个库</h2>
<p>打开终端（Windows 用 PowerShell 或 CMD）：</p>
<pre><code>pip install requests lxml
</code></pre>
<table>
<thead>
<tr>
<th>库</th>
<th>干什么用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>requests</code></td>
<td>帮你发送 HTTP 请求，把网页"下载"下来</td>
</tr>
<tr>
<td><code>lxml</code></td>
<td>把下载到的 HTML 解析成一棵树，方便按结构取数据</td>
</tr>
</tbody>
</table>
<p>:::tip 下载太慢？
可以换国内镜像：<code>pip install requests lxml -i https://pypi.tuna.tsinghua.edu.cn/simple</code>
:::</p>
<h2>1. 先让代码跑起来：三行发一个请求</h2>
<p>先别想太多，三行就能发出你人生中第一个爬虫请求：</p>
<pre><code>import requests  # 发送请求需要的库

url = "https://www.bqgyy.net/book/4708/1251881.html"  # 目标章节页的 URL
resp = requests.get(url)  # 用 GET 方式请求，resp 用来接收服务器返回的结果
print(resp.text)  # 把响应内容按字符串打印出来
</code></pre>
<p><img src="./01-first-request.webp" alt="第一版：三行代码" /></p>
<p>这里有个值得记住的点：<strong><code>requests.get</code> 里的 <code>get</code> 不是固定的</strong>。它叫"请求方法"，取决于目标网站用的是什么。所以下一步，我们得先去确认。</p>
<h2>2. 用浏览器 DevTools 找到"正确姿势"</h2>
<p>浏览器开发者工具（DevTools）是爬虫学习阶段最好的老师：网站向服务器要了什么、用什么方式要的，它都记着。</p>
<h3>2.1 打开检查面板</h3>
<p>在目标页面上<strong>右键 → 检查</strong>（或按 <code>F12</code>）：</p>
<p><img src="./02-devtools-elements.webp" alt="右键点击检查" /></p>
<h3>2.2 切到 Network 面板并刷新</h3>
<p><img src="./03-network-panel.webp" alt="Network 面板" /></p>
<h3>2.3 找到第一个请求</h3>
<p>刷新页面后，列表里第一条通常就是这份 HTML 文档本身：</p>
<p><img src="./04-network-first-request.webp" alt="刷新后找到第一个请求" /></p>
<h3>2.4 看它的请求方法</h3>
<p>点进这条请求，找到 <code>Request Method</code>：</p>
<p><img src="./05-request-method-get.webp" alt="Request Method: GET" /></p>
<p>网站用的是 <strong>GET</strong>，所以代码里也用 <code>.get()</code>。如果人家用的是 POST，你就得改成 <code>.post()</code> 并按表单格式传参数，方法要跟网站一致，不是你想用哪个就用哪个，至于什么是GET，什么事POST，相信你可以从各种AI处得到答案。</p>
<h2>3. 伪装成浏览器：User-Agent</h2>
<h3>3.1 为什么会被拒绝</h3>
<p>你在浏览器里读小说，网站把你当"正常访客"；但代码发出去的请求带着一身"我是程序"的痕迹，那你的胆子真是肥嘟嘟的，这很容易被识别出来，然后被拒之门外，如果你之前了解过某些网站短时间被大量同一IP访问会被封禁的概率很高这一概念的话但不知道为什么的话，结合上述内容把不懂的内容再问问DeepSeek大人，你现在应该知道为什么了。</p>
<h3>3.2 线索还是藏在 Network 里</h3>
<p>回到刚才那条请求，点开 <strong>Headers</strong>，找到 <code>Request Headers</code>，一直拉到 User-Agent：</p>
<p><img src="./06-request-headers.webp" alt="请求头详情" /></p>
<p><img src="./07-user-agent-header.webp" alt="User-Agent 那一行" /></p>
<p>从这一行能读出两件事：</p>
<ul>
<li>操作系统：<code>Windows NT 10.0; Win64; x64</code>（64 位 Windows）</li>
<li>浏览器：<code>Chrome/124.0.0.0</code></li>
</ul>
<h3>3.3 把它写进代码</h3>
<pre><code>feign = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}

resp = requests.get(url, headers=feign)  # 通过 headers 把自己伪装成浏览器
</code></pre>
<p><img src="./08-code-with-headers.webp" alt="带上 headers 的请求" /></p>
<p>:::note 别被大括号吓到
Python 里的 <code>{}</code> 不是 C 语言那种代码块，而是<strong>字典</strong>。<code>feign</code> 只是个变量名（当成"伪装"来记就好），你写 <code>headers</code> 也一样。
:::</p>
<h2>4. 满屏乱码？先设置编码</h2>
<p>兴冲冲地运行，结果输出里除了正常汉字，还夹着一堆乱七八糟的东西：</p>
<p><img src="./09-output-mojibake.webp" alt="输出乱码" /></p>
<p>这十分有十二分不对劲，<strong>编码不一致</strong>就是原因。服务器返回的是一串字节，得用正确的规则解码才能得到正常文字。加一行：</p>
<pre><code>resp.encoding = "utf-8"  # 告诉 requests 按 utf-8 来解码
</code></pre>
<p><img src="./10-output-after-utf8.webp" alt="加上编码后的输出" /></p>
<p>（UTF-8是什么呢，你知道的朋友，请移步Deep Seek或者各类计算机科普视频）好多了。但现在拿到的还是<strong>整页 HTML</strong>，导航、广告、"推荐阅读"全混在里面。我们要的只是正文。</p>
<h2>5. 用 lxml + XPath 把正文"挑"出来</h2>
<h3>5.1 引入 lxml</h3>
<pre><code>from lxml import etree  # 把 HTML 交给它解析
</code></pre>
<p><img src="./11-import-lxml.webp" alt="import etree" /></p>
<h3>5.2 找到正文的容器</h3>
<p>回到 DevTools 的 Elements 面板，你会发现正文每一段都被 <code>&lt;p&gt;</code> 标签包着：</p>
<p><img src="./12-source-p-tags.webp" alt="源码里的 p 标签" /></p>
<p>而这些 <code>&lt;p&gt;</code> 都躺在同一个容器里（比如 <code>&lt;div class="content"&gt;</code>）：</p>
<p><img src="./13-source-article.webp" alt="article 容器" /></p>
<h3>5.3 写 XPath 选中它们</h3>
<p>XPath 是"在 HTML 树里找节点"的语法。不用背，装个 <strong>XPath Helper</strong> 插件，在页面上边试边看：</p>
<p><img src="./14-xpath-helper.webp" alt="用 XPath Helper 调试" /></p>
<p>确认能选中之后，写进代码：</p>
<pre><code>e = etree.HTML(resp.text)  # 把响应文本解析成 HTML 树
info = "\n".join(e.xpath('//div[@class="content"]/text()'))  # 取出正文并拼接
</code></pre>
<p><img src="./15-join-code.webp" alt="join 拼接" /></p>
<p>这条 XPath 分三层：</p>
<ol>
<li><code>//div[@class="content"]</code>：在整个文档里找 <code>class="content"</code> 的 div</li>
<li><code>/text()</code>：取它内部的所有文本节点</li>
<li><code>"\n".join(...)</code>：用换行符把每一段拼成一整块文本</li>
</ol>
<p>:::warning 为什么不用正则表达式？
正则当然也能抠出来，但 HTML 结构一旦变动就会失效，嵌套标签也会让表达式迅速失控。XPath 是按"结构"定位的，可读性和可维护性都更好。
:::</p>
<h2>6. 写进文件</h2>
<pre><code>with open("异兽迷城.txt", "a", encoding="utf-8") as f:
    f.write(info)
</code></pre>
<ul>
<li><code>"a"</code> 是<strong>追加</strong>模式：每抓完一章就往文件尾部追加，不会覆盖前面的内容</li>
<li><code>encoding="utf-8"</code> 别漏，否则在 Windows 上容易报编码错误</li>
</ul>
<h2>7. 变成循环：抓一整本</h2>
<p>一章的内容到手之后，"抓一整本"其实就是把上面的流程循环起来。思路拆成八步：</p>
<p>:::steps[抓取一整本的流程]</p>
<ol>
<li>
<p><strong>给出目标 URL</strong></p>
<p>从第一章的地址开始。</p>
</li>
<li>
<p><strong>伪装自己</strong></p>
<p>准备好带 <code>User-Agent</code> 的 <code>headers</code>。</p>
</li>
<li>
<p><strong>发送请求</strong></p>
<p>用 <code>requests.get(url, headers=feign)</code>。</p>
</li>
<li>
<p><strong>接收并解码</strong></p>
<p>用变量接住响应，设置 <code>resp.encoding = "utf-8"</code>。</p>
</li>
<li>
<p><strong>提取正文</strong></p>
<p>用 lxml 和 XPath 取出这一章的文本，并做简单拼接。</p>
</li>
<li>
<p><strong>找到"下一章"链接</strong></p>
<p>在页面里用 XPath 定位"下一章"按钮，取出它的 <code>href</code>。</p>
</li>
<li>
<p><strong>循环</strong></p>
<p>用 <code>while True</code> 重复以上步骤，并设置退出条件。</p>
</li>
<li>
<p><strong>写入文件</strong></p>
<p>每循环一次，就把这一章追加进 txt。
:::</p>
</li>
</ol>
<p>完整代码：</p>
<pre><code>import requests  # 发送请求
from lxml import etree  # 解析 HTML

url = "https://www.oknovel.net/chapter/1634382395543396353/2"  # 起始章节

while True:
    feign = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                      "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
    }

    resp = requests.get(url, headers=feign)  # 发请求
    resp.encoding = "utf-8"  # 按 utf-8 解码

    e = etree.HTML(resp.text)  # 解析成 HTML 树
    info = "\n".join(e.xpath('//div[@class="content"]/text()'))  # 取正文

    with open("异兽迷城.txt", "a", encoding="utf-8") as f:
        f.write(info + "\n")

    # 取"下一章"链接：section-opt 里的第 3 个 a 标签
    next_path = e.xpath('//div[@class="section-opt"]/a[3]/@href')[0]
    url = "https://www.oknovel.net" + next_path

    # 已到最后一章，收工
    if url == "https://www.oknovel.net/chapter/1634382395543396353/1424":
        break
</code></pre>
<p><img src="./16-full-script.webp" alt="完整脚本" /></p>
<p>:::caution 这里的退出条件很"脆"
上面用"最后一章 URL 是否相等"来判断结束，因为写的时候就已经知道末章地址了。一旦章节变动，这个条件就会失效。更稳的写法是：<strong>取不到"下一章"链接就退出</strong>。下面给一个加强版。
:::</p>
<h3>更稳一点的版本</h3>
<pre><code>import time

import requests
from lxml import etree

BASE = "https://www.oknovel.net"
url = BASE + "/chapter/1634382395543396353/2"
seen = set()

with open("异兽迷城.txt", "a", encoding="utf-8") as f:
    while url and url not in seen:
        seen.add(url)

        resp = requests.get(
            url,
            headers={
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                              "AppleWebKit/537.36 (KHTML, like Gecko) "
                              "Chrome/124.0.0.0 Safari/537.36"
            },
            timeout=10,
        )
        resp.encoding = "utf-8"

        e = etree.HTML(resp.text)
        info = "\n".join(e.xpath('//div[@class="content"]/text()'))
        f.write(info + "\n")

        # 取不到"下一章"就结束
        next_path = e.xpath('//div[@class="section-opt"]/a[3]/@href')
        url = BASE + next_path[0] if next_path else None

        time.sleep(1)  # 歇一秒，别把人家服务器打疼了
</code></pre>
<p>改动点：</p>
<ul>
<li>用 <code>seen</code> 记录抓过的地址，避免死循环</li>
<li><code>next_path</code> 取不到就直接结束，不必事先知道末章 URL</li>
<li>加 <code>timeout</code>，避免请求卡死</li>
<li>每章之间 <code>sleep(1)</code>，降低对服务器的影响</li>
</ul>
<h2>8. 新手最容易踩的几个坑</h2>
<p>:::details 报错 ModuleNotFoundError: No module named 'requests'
库没装上，或者装到了别的 Python 环境里。确认运行代码的解释器和 <code>pip install</code> 用的是同一个。
:::</p>
<p>:::details 抓回来是乱码
先试 <code>resp.encoding = "utf-8"</code>；如果还乱，看看网页源码 <code>&lt;meta charset="..."&gt;</code> 里写的是什么，跟着改。
:::</p>
<p>:::details 返回内容是空的，或者提示禁止访问
多半是反爬。先检查 <code>headers</code> 有没有带全（至少要有 <code>User-Agent</code>）；有些站点还会校验 <code>Referer</code>、<code>Cookie</code>。也可能是请求太快被限流了，放慢速度再试。
:::</p>
<p>:::details 正文里混进了广告和"推荐阅读"
说明 XPath 选的范围太大。回到 DevTools，用 XPath Helper 逐步收窄，只框住真正放正文的那个容器。
:::</p>
<p>:::details 每章粘在一起，没有换行
写入时补一个换行即可：<code>f.write(info + "\n")</code>。
:::</p>
<h2>9. 关于合规，必须说几句</h2>
<p>:::warning
这篇文章的目的是让你理解 HTTP 请求与 HTML 解析，而不是鼓励你去搬运别人的内容。</p>
<p>请务必注意：遵守目标网站的 <code>robots.txt</code> 与服务条款；控制请求频率，不要对服务器造成压力；抓取到的内容仅作个人学习使用，不要传播、转载或用于商业用途。小说是有著作权的作品。
:::</p>
<h2>全流程对照</h2>
<p>整件事其实只有三个关键词：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>工具</th>
<th>解决什么问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>拿到页面</td>
<td><code>requests</code></td>
<td>让程序代替浏览器发请求</td>
</tr>
<tr>
<td>读对内容</td>
<td><code>resp.encoding</code></td>
<td>别让编码把你的中文变成乱码</td>
</tr>
<tr>
<td>取出数据</td>
<td><code>lxml</code> + XPath</td>
<td>从一堆 HTML 里精准挑出正文</td>
</tr>
</tbody>
</table>
<p>刚学一门语言的时候，最难得的不是"学会了多少语法"，而是<strong>亲手做出一个能跑起来的东西</strong>。哪怕它只是把一本小说存成 txt，那种"我写的东西真的在干活"的感觉，才是让人继续学下去的动力。</p>
<p>如果你用它抓到了有意思的数据，或者踩到了什么奇怪的坑，欢迎来<a href="/about/">关于页</a>找我聊聊。</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="教程"/>
  </entry>
  <entry>
    <title>有感</title>
    <link href="https://cnyicheng.top/posts/reflections-on-ai-era/" rel="alternate" type="text/html"/>
    <id>https://cnyicheng.top/posts/reflections-on-ai-era/</id>
    <published>2026-09-17T00:00:00.000Z</published>
    <updated>2026-09-17T00:00:00.000Z</updated>
    <summary>一篇转载随笔：从 DeepSeek v4.1 说起，聊聊 AI 时代里一个算子工程师的骄傲、失落与选择。</summary>
    <content type="html"><![CDATA[<blockquote>
<p>我不得不把才华埋葬在昨天</p>
</blockquote>
<p>前几天，DeepSeek v4.1 发布了，将小模型能力的高度又向上推进了一个档次。</p>
<p>AI 发展的速度远远超过了所有人的预期。从那个只会咿呀学语地聊天、上下文长度只有几千 token 的初版 ChatGPT，到具有推理能力的 OpenAI o1、DeepSeek R1 与 Kimi K1.5 Thinking，只不过短短两年；从推理模型到如今能够流畅地在各类 harness 工具中执行命令、完成复杂任务的智能体，也不过一年半。很难想象，倘若再等上一年、两年、三年，彼时的 AI 会成为什么样子，会有多么强大，会不会已经具备了自我进化的能力，并深度渗透进了具身智能等领域。</p>
<h2>AI 越来越会写算子了</h2>
<p>AI 在我所从事的算子设计、编写这一领域同样进步飞速，在短短一年的时间内，他已经从一个只能帮我查查文档、读读代码、找找 bug 的小助手，蜕变成了一位能够独立阅读 CUDA、PTX 与 SASS 编码、通过专业工具分析每条指令的停顿时间、进而独立优化算子的算子大师。相信在不久的未来，它也能拥有自己独立设计算子调度、评估不同调度方案的性能、将其实现并优化的能力。</p>
<p>我当然为 DeepSeek v4.1 的成功而骄傲 —— 毕竟它的主 Attention 算子都是我写的 [^1]，它的优秀正是对我的算子的一份肯定。但是，时代的车轮滚滚向前，技术的发展无人能挡。我很清楚，再过上半年或者一年，AI 写的算子大概率就会和我写得同样优秀，甚至将我超越。AI 能一秒思考 300 个 token、半秒敲出一行命令、二十秒写完一份代码，而我不行；AI 能在模型深度、思考强度、工具调用量（和环境交互的频率）、甚至并行度等方面都能不断提升，而我不能。</p>
<p>人类在毁灭自己这件事情上，自古以来都表现得毫不犹豫。为什么在明知“我算子写得越好，我们的新模型的训练、推理速度就会越快，模型能力进步就会更快，我就会更早地被取代”的情况下，我仍然选择尽力优化算子呢？一方面确实是因为写算子对我来说就像打游戏一样，能为我提供极大的快感。我在发明了一种新技术、或者看到自己算子的性能上升的那一刻，心中的激动程度不亚于游戏的速通玩家打破了自己过往的记录。同时，当看到自己的算子的性能远超厂商官方的算子时，我心中也会萌生极大的自豪感。但除此之外，一个更重要的原因是，哪怕我就此“摆烂”甚至故意下绊子耽误模型训练，其它家的模型也会照常发展并最终将我照杀不误。“我当然希望自己不要被革命，但如果非被革命不可的话，我希望革我自己命的人是我自己”。在大家都这么执着于毁灭自己的时候，我也不得不加入这场残酷的军备竞赛。</p>
<h2>那我呢</h2>
<p>等到 AI 写算子的水平真的高于我的那天，届时的我会怎么样呢？</p>
<p>我的判断是：我不至于会“失业”，但必须要“转业”。我的饭碗尚且能保住，但这可能会导致我再也没机会从事那份我曾热爱过的工作。</p>
<p>我曾经对时代的变化与我个人在未来的处境做出过一个判断：由于时代变化真的太快（上文的 AI 发展就是一个很好的例子），我完全无法预知五年、十年后会发生什么，但不论如何，我相信凭借着自己的眼界、判断力、主观能动性与智力，留在时代的牌桌上，并重新立于时代的潮头。但是，这个判断只能保证我不会“失业”，而无法保证我不需要“转业”，倒不如说这个判断鼓励我通过转业来避免失业。</p>
<p>那转业代表什么呢？它代表着我需要放弃我深耕已久并充满热爱的算子设计、编写、优化领域，转而去做 Agent 的“机甲驾驶员”。在之前，我的兴趣、我所擅长的、以及工业界所需要的，三者是基本对齐的；而现在，AI 让我所擅长的变成了它更擅长的，也让工业界的需求从“会写高性能算子的人”漂移到了“能用 AI 更快地产出高性能算子的人”。为了适应工业界的需求，我势必要放弃之前那个我热爱的方向，转向一个未知的新方向。我相信我能凭借着自己对于工程学、上层模型需求和底层硬件的理解，继续高质量、高效率地产出算子，我也知道我可能会热爱这个新方向（也可能不会），但被夺走热爱的感觉，确实不太好受。那份坐在工位上静心写上一下午算子的清欢，可能会在这个夏天成为绝唱。我不得不把才华埋葬在昨天，去做一位机甲驾驶员。我的手中多了些齿轮，但心中少了些节拍。</p>
<p>可以打个形象的比方：你精通织毛衣技术，尤其擅长各种图案的织造与各色色彩的搭配。你所织出的毛衣质量过硬且花纹美观，十里八乡的富人都来请你为他们织毛衣，你借此赚到了不少钱。同时，你十分享受着那种坐在窗边，沏一壶清茶，望着窗外的青山、绿水、牛羊与炊烟，静静地织上一下午毛衣的感觉。但有一天，有人发明出了一台神奇的机器，只需提供毛线与图案，便可自动织出毛衣，质量与纹理都不亚于你亲手织造的，且速度远快于你。你很清楚，你的同行可以凭着这台机器轻松达到你曾经的水平，因此你不得不也去用它。你也知道，凭借着你过去二十年攒下的织毛衣技术，哪怕大家都有机器，你织毛衣的速度与质量也还能超过同行。但那份临窗听雨、引针穿线、慢度光阴的意趣，终究还是被机器的轰鸣碾碎了。</p>
<p>我知道这很无奈，但没办法。饭碗可以保住，但旧日的热爱大概率是要放弃的。我是一个理性和感性分离得比较开的人，在需要用理性处理问题时可以很理性，但有时也会表现出感性的一面。我记得我在搬离住了一年的出租屋时，还大哭了一场，舍不得和过去的记忆分别。今天和之前那个手写算子、人脑优化的时代告别，无疑比这更加残酷。</p>
<p>不知道有没有读者有类似的感受，但我想这事儿也只能这样了。</p>
<h2>那人们呢</h2>
<p>在 AI 不断进步的同时，我也对一些问题表示担忧：</p>
<p>现在的学生是不是大概率会更倾向于使用 AI 完成作业，特别是偏向于实践的各种 Lab？想象一下，如果面前有两个选择，一个是苦哈哈地用八小时时间完成一个 Lab，或许还拿不到满分；另一个则是启动 AI 模型，用几毛钱的成本、几分钟的时间，直接让 AI 编写满分代码，那大部分学生会选择哪个呢？</p>
<p>上面一点会导致大量学生的工程能力严重不足，包括组织代码的能力、构建系统的能力、思考未来潜在需求并提前在设计上应对的能力、抽象的能力等等。那么在 AI 能力不断变强的背景下，这部分“工程能力”是否还是必须的呢？这些工程能力是会向旧日的“熟练编写 x86 汇编”的能力那样逐渐被时代抛弃，还是会像“理解从软件到系统再到硬件的整套计算机系统”的能力那样永远具有价值？如果是后者的话，那就危险了 —— 一个工程能力很差的人，在搭配上 AI 后，产出屎山的效率可以达到先前的数倍，进而给系统埋下各式祸患，让这个世界变得更加草台。</p>
<p>在未来社会中，权力（power）是不是会比技术或智商更加重要？</p>
<p>这些问题，或许就需要时代本身来回答了。</p>
<h2>结语</h2>
<p>伴随着 AI 的发展，未来的社会可能会趋向于两个极端：共产主义与赛博朋克 2077。在前者中，生产力得到极大的解放，人们的生活水平有了明显的提高（就写这些吧不然我怕过不了审）；而在后者中，少数科技公司控制着大部分资源，只有极少数人能够使用最先进的 AI 和各式科技，获得接近“机械飞升”的效果，大部分人则只能用上很孱弱的 AI。阶层跨越将越来越难实现：你得先有最强的 AI，才能跨越阶层，形成了一种死循环。</p>
<p>你猜猜如果 Anthropic 公司永远掌握着这个世界上最先进的 AI，未来社会是会变成共产主义还是 2077 呢？你猜？</p>
<p>所以，我还是相信，最前沿的智能应该以一种开放、廉价的方式，供应给所有人。我不信任 Anthropic 或者 OpenAI 能这样做，特别是不希望 Anthropic 掌握最先进的人工智能或 AGI，夸张点说其严重性不亚于让希特勒先于盟军掌握原子弹技术。这也是为什么我选择并坚持留在了 DeepSeek：我们研究强大、快速、普惠的人工智能并将其开源，或许能把世界从 2077 那端拉回来一些。</p>
<p>愿未来的世界一切安好。May all the beauty be blessed.</p>
<p>[^1]: “主 Attention”仅包括 head dim = 512 的 MQA attention，不包括用于选出 top-k 重要的 token 的 indexer，那部分是由其他（水平也非常强的）同事（以及他们的 AI Agent）编写的。</p>
<p>:::note 关于本文
本文系<strong>转载</strong>，来源与作者信息如下：</p>
<ul>
<li><strong>来源</strong>：微信公众号「intlsy的狗窝」</li>
<li><strong>作者</strong>：刘胜与，DeepSeek 员工（算子工程师）</li>
</ul>
<p>版权归原作者所有，本文仅作个人存档与分享。若原作者认为不妥，请联系删除。
:::</p>
]]></content>
    <author><name>伊橙</name></author>
    <category term="日常"/>
  </entry>
</feed>
