<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://onlyforchris.github.io/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://onlyforchris.github.io/blog/" rel="alternate" type="text/html" /><updated>2026-10-15T03:51:33+00:00</updated><id>https://onlyforchris.github.io/blog/feed.xml</id><title type="html">Chris · 作品与笔记</title><subtitle>做点有用的，留点好玩的。HTML 演示、财务小工具、招聘与文档处理 Skill，以及做项目时留下的工程笔记。</subtitle><author><name>Chris</name></author><entry><title type="html">让 AI 帮你写代码，又不失控：三条原则</title><link href="https://onlyforchris.github.io/blog/ai-coding-without-losing-control/" rel="alternate" type="text/html" title="让 AI 帮你写代码，又不失控：三条原则" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/ai-coding-without-losing-control</id><content type="html" xml:base="https://onlyforchris.github.io/blog/ai-coding-without-losing-control/"><![CDATA[<p>AI 写代码最大的风险，不是它写不出来，而是<strong>你把活交给它之后，就失去了对项目的掌控</strong>。我见过太多例子：全交给 AI 跑长任务，做完之后自己都不知道代码变成什么样，出问题也定位不了；还有人拿 AI 改别人写的代码，改着改着就把一处全局变量改坏了，甚至自己都没察觉。</p>

<p>AI 是<strong>放大器</strong>：你自己越清楚”要什么、边界在哪、怎么验证”，它越好用；反过来，你越模糊，它就把你的模糊放大成更大的乱子。这篇讲三条原则，帮你既用上 AI、又不失控。</p>

<h2 id="原则一边界你定内部交给-ai">原则一：边界你定，内部交给 AI</h2>

<p>把”接口契约”攥在自己手里，把”内部实现”扔给 AI。</p>

<ul>
  <li><strong>你负责定义</strong>：输入是什么、输出是什么、字段叫什么、边界规则（比如”这个值是全局变量，谁都别乱动”）。</li>
  <li><strong>AI 负责实现</strong>：在边界里面把它做出来。</li>
</ul>

<p>这样做的好处是：即使 AI 在内部改得乱七八糟，<strong>只要边界不变，它波及不到外部</strong>。出问题你也知道问题在”内部实现”这一块，而不是”整条链路都塌了”。</p>

<p>反过来，如果你连边界都放任 AI 定，它一改，可能把”只有一个入口”“必须幂等”这种你默认为真的约定给打破——那才是最要命的。</p>

<h2 id="原则二小步走可验证别让-ai-一次改一大片">原则二：小步走、可验证，别让 AI 一次改一大片</h2>

<p>这是最容易被忽略、也最容易出事的。</p>

<ul>
  <li><strong>一次只让它改一个点</strong>，改完马上跑一下相关用例。</li>
  <li>出问题，范围很小，一眼就能定位。</li>
  <li>如果让它一次”重构整个文件/整个模块”，出错了你根本不知道是哪一步引入的，排查成本直接爆炸。</li>
</ul>

<p>AI 帮你在每个小步里做<strong>验证</strong>（编译、跑测试、看结果），你就能随时确认”这步没问题”。小步，是<strong>可控</strong>的前提。</p>

<h2 id="原则三你保留看懂和回滚的权力">原则三：你保留”看懂”和”回滚”的权力</h2>

<p>这是底线。AI 改的代码，你<strong>得能看懂</strong>，<strong>得能一键回滚</strong>。</p>

<ul>
  <li>看懂：不是为了逐行读，而是为了<strong>能判断”它把什么改了”</strong>。如果 AI 悄悄改了别人的文件、改了一个你没授权的角落，你要看得出来。</li>
  <li>回滚：每一次改动都要有清晰、可回退的提交/分支。这样即使 AI 改坏了，你<strong>一条命令就能回到上一个能跑的状态</strong>，而不是在一堆混乱里手忙脚乱。</li>
</ul>

<p><strong>“看不到改动”是最危险的状态</strong>——你不知道它碰了哪里，自然就不知道该怎么救。</p>

<h2 id="落到具体两种碰不得的东西">落到具体：两种”碰不得”的东西</h2>

<p>有些东西，别让 AI 随便碰：</p>

<ul>
  <li><strong>全局变量、共享状态</strong>：这类东西牵一发而动全身，AI 常常”好心地”帮你改，结果连锁炸掉。宁可你自己手动改。</li>
  <li><strong>别人/别处的代码</strong>：如果不是你的模块，<strong>别让 AI 去动</strong>。因为它没有你对那个模块的理解，改了很容易越界。</li>
</ul>

<h2 id="一句话">一句话</h2>

<p>AI 是生产力，但它<strong>不会为你负责</strong>。你越早把”边界、验证、回滚”这三点立起来，它就越是从”帮你走捷径”变成”帮你干活”，而不是”替你做决定”。</p>

<p><strong>让 AI 干活可以，但方向盘和刹车，得一直握在自己手里。</strong></p>]]></content><author><name>Chris</name></author><category term="AI 协作" /><category term="代码质量" /><category term="工程纪律" /><category term="掌控" /><summary type="html"><![CDATA[AI 写代码最大的风险，不是它写不出来，而是你把活交给它之后，就失去了对项目的掌控。我见过太多例子：全交给 AI 跑长任务，做完之后自己都不知道代码变成什么样，出问题也定位不了；还有人拿 AI 改别人写的代码，改着改着就把一处全局变量改坏了，甚至自己都没察觉。]]></summary></entry><entry><title type="html">接一个”黑盒”系统：看不懂文档时的三招</title><link href="https://onlyforchris.github.io/blog/blackbox-integration-three-moves/" rel="alternate" type="text/html" title="接一个”黑盒”系统：看不懂文档时的三招" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/blackbox-integration-three-moves</id><content type="html" xml:base="https://onlyforchris.github.io/blog/blackbox-integration-three-moves/"><![CDATA[<p>接入系统时，最难受的不是”功能复杂”，而是<strong>对方是个黑盒</strong>：没有文档、没源码、甚至只有个 jar 包或一个”只进不出”的服务。你不能问它”你为什么这样”，只能靠观察和试探。这篇讲三招，都是我在接这类东西时摸出来的，能少走很多弯路。</p>

<h2 id="黑盒的本质一个输入输出的映射">黑盒的本质：一个”输入→输出”的映射</h2>

<p>先纠正一个心态：你<strong>不需要”看懂”黑盒</strong>，你需要的是<strong>摸清它的”边界”</strong>。黑盒系统本质上就是”给定某类输入，能吐出某类输出”。你只要知道：</p>

<ul>
  <li>它接受什么（参数、格式、触发方式）；</li>
  <li>它吐出什么（返回值、结构、状态）；</li>
  <li>它有什么<strong>运行特性</strong>（单线程还是多线程、能不能中途改状态、能不能暂停恢复）。</li>
</ul>

<p>把这些摸清楚，你就能用起来了，哪怕内部你一无所知。这比硬读它的内部要快得多。</p>

<h2 id="第一招用最小输入去试探边界">第一招：用最小输入去试探边界</h2>

<p>别一上来就想着”我得搞懂它的完整功能”。先做<strong>最小试探</strong>：给一个最简单的输入，看它返回什么；换一个字段，看返回值怎么变。几次下来，它的”输入输出契约”就浮出来了。</p>

<p>这一步的关键是<strong>让变化可控</strong>：一次只改一个变量，观察结果。这样你才能把”哪个输入影响了哪个输出”对应上，而不是被一堆变化搞晕。</p>

<h2 id="第二招读产物而不是等文档">第二招：读”产物”，而不是等文档</h2>

<p>如果它是个 jar、zip、或者编译产物，<strong>别等文档</strong>，直接看它自己暴露出来的东西：</p>

<ul>
  <li><strong>反编译看类名、方法签名、常量</strong>：很多”控制、运行、暂停、初始化”这种语义明显的入口，反编译就能看到。市面上没资料时，源码包里就有。</li>
  <li>看它的<strong>配置、示例、元信息</strong>：类名、字段名、甚至注释，往往比说明书更准。</li>
  <li>找它<strong>对外那个”口”</strong>在哪儿：哪些是入口、哪些是回调、哪些是它想让你调的。</li>
</ul>

<p>这一步的收益极大：你以为是”没人知道的东西”，其实大多就藏在它自己的产物里。</p>

<h2 id="第三招先找最稳的那一种启动方式别试花活">第三招：先找”最稳”的那一种启动方式，别试花活</h2>

<p>黑盒系统往往能”用多种方式跑”，但<strong>真正稳的只有一种</strong>。比如你手里有个 Java 的包，那就老老实实用 <strong>Java 跑</strong>，别拿 Python 去启动它——语言和运行环境不匹配，只会引入一堆无关的坑。</p>

<p>我的做法是：<strong>先找到那一个能跑通的最小链路，把它跑起来，再在这个基础上加东西。</strong> 一开始就追求花哨的启动/集成方式，很容易把”环境不匹配”的错，当成”系统做不了”的错觉。</p>

<h2 id="别忘了运行特性这个软信息">别忘了”运行特性”这个软信息</h2>

<p>黑盒除了输入输出，还有一类<strong>用文档看不出来、只能试出来的特性</strong>：</p>

<ul>
  <li>它是<strong>单线程</strong>的吗？（单线程意味着你”中途往里塞东西”很受限，只能暂停、改、再跑）</li>
  <li>它能<strong>暂停/恢复</strong>吗？暂停时是”真的停”还是”只是不往下走”？</li>
  <li>它中间能不能<strong>改状态</strong>？改的话，已经过去的部分会不会被锁死？</li>
</ul>

<p>这些特性决定你能不能”插队”、能不能热更新。<strong>越早摸清这些，越少在后面被它卡住。</strong></p>

<h2 id="最后三句">最后三句</h2>

<ul>
  <li>黑盒集成的目标不是”看懂它”，是”<strong>摸清它的输入输出和运行特性</strong>“。</li>
  <li>先<strong>最小试探 + 读产物</strong>，比等文档快得多。</li>
  <li>找到<strong>最稳的那一种跑法</strong>，把一个最小链路跑通，再往上堆。</li>
</ul>

<p>跟黑盒打交道，靠的不是了解，是<strong>观察、试探、记录</strong>。把这三招用熟，你会发现”没文档”其实没那么可怕。</p>]]></content><author><name>Chris</name></author><category term="黑盒集成" /><category term="逆向" /><category term="系统对接" /><category term="方法" /><summary type="html"><![CDATA[接入系统时，最难受的不是”功能复杂”，而是对方是个黑盒：没有文档、没源码、甚至只有个 jar 包或一个”只进不出”的服务。你不能问它”你为什么这样”，只能靠观察和试探。这篇讲三招，都是我在接这类东西时摸出来的，能少走很多弯路。]]></summary></entry><entry><title type="html">渠道验签差异，是接入的隐性成本</title><link href="https://onlyforchris.github.io/blog/channel-signature-cost/" rel="alternate" type="text/html" title="渠道验签差异，是接入的隐性成本" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/channel-signature-cost</id><content type="html" xml:base="https://onlyforchris.github.io/blog/channel-signature-cost/"><![CDATA[<p>接入 IM 机器人，很多人只想到”收发消息”，容易忽略一个隐性但很重要的成本：<strong>每个渠道的 webhook 验签方式都不一样</strong>。它藏得深，但一旦做错，轻则收不到消息，重则等于<strong>把入口对伪造请求敞开</strong>。</p>

<p>我在做多端 IM 接入（<a href="https://github.com/onlyforchris/dsh-im">dsh-im</a>）时，9 个渠道在这里花的功夫，比想象中多得多。这篇讲验签差异为什么会成为隐性成本。</p>

<h2 id="为什么验签这么关键">为什么验签这么关键</h2>

<p>webhook 是渠道把事件推给你的<strong>公开入口</strong>。任何能访问到这个 URL 的人，都能伪装给系统”喂消息”。如果没有可靠的验签，就等于<strong>任何人都能冒充渠道给你的机器人发指令</strong>。</p>

<p>所以 webhook 处理的第一件事不是”解析消息”，而是<strong>先验签，确认这条消息真的是渠道发的</strong>。验签不过，直接拒收，别往下走。</p>

<h2 id="差异在哪">差异在哪</h2>

<p>麻烦就麻烦在，<strong>“验签”没有统一标准</strong>，每个渠道一套：</p>

<ul>
  <li><strong>签名算法</strong>：有的是 HMAC-SHA256，有的是 SHA1，有的是 RSA，算法还不一样；</li>
  <li><strong>怎么拼</strong>：用什么字段、什么顺序、要不要带上时间戳、要不要加盐，各渠道定义不同；</li>
  <li><strong>放哪</strong>：签名在 header 里，还是 body 里，还是 URL 参数里；</li>
  <li><strong>是否带时间戳</strong>：有的会校验 <code class="language-plaintext highlighter-rouge">timestamp</code> 防重放，有的不会；</li>
  <li><strong>密钥从哪来</strong>：应用凭据里的 secret，还是单独的 token。</li>
</ul>

<p>同一套代码，换个渠道就要重新写一遍验签，还不能复用。</p>

<h2 id="处理思路验签也走适配器">处理思路：验签也走”适配器”</h2>

<p>好消息是，验签虽然每渠道不同，但它和”消息格式转换”是同一类问题——<strong>渠道差异，收敛到适配器里</strong>。</p>

<p>所以我把验签也放进<strong>每个渠道的适配器</strong>：适配器内部实现”这个渠道怎么验”，对外暴露一个统一的接口，比如 <code class="language-plaintext highlighter-rouge">bool verify(event, headers, body)</code>。上层只调用这个，不用知道底下是谁、用什么算法。</p>

<p>这样做的<strong>安全价值</strong>很直接：</p>

<ul>
  <li><strong>新渠道 = 新适配器里写一套验签</strong>，不会污染上层；</li>
  <li>上层不用为每个渠道写”敏感字段判断”，降低了把密钥/敏感信息泄漏出去的风险；</li>
  <li>一旦某个渠道的验签要改，只动那个适配器，影响面可控。</li>
</ul>

<h2 id="小心两个坑">小心两个坑</h2>

<ol>
  <li><strong>密钥管理</strong>：各渠道的 secret/token 不能硬编码、不能打进日志。要集中管理、脱敏。这也是为什么适配器里要<strong>严格脱敏</strong>呼吸——别把敏感串原样抛给上层或日志。</li>
  <li><strong>验签失败要有可观测</strong>：验签失败不应该静默丢弃，至少要能看出来”是不是验签挂了/密钥错了/时间戳不对”。否则你会以为”没消息”，其实是”全被拒了”。</li>
</ol>

<h2 id="最后几条">最后几条</h2>

<ul>
  <li>webhook 处理<strong>先验签</strong>，再谈解析；验签不过直接拒收。</li>
  <li>“验签没统一标准”是常态，<strong>每渠道一套</strong>，别指望复用。</li>
  <li>验签走上层统一接口、实现在适配器里，新渠道=新适配器一套验签。</li>
  <li>密钥集中管理、严格脱敏；验签失败要有日志和告警。</li>
</ul>

<p>验签看起来是小事，但它决定了你的入口<strong>是开着还是关着</strong>。把这个隐性成本认清，是接入多渠道很重要的一步。</p>]]></content><author><name>Chris</name></author><category term="安全" /><category term="验签" /><category term="IM 接入" /><category term="适配器" /><summary type="html"><![CDATA[接入 IM 机器人，很多人只想到”收发消息”，容易忽略一个隐性但很重要的成本：每个渠道的 webhook 验签方式都不一样。它藏得深，但一旦做错，轻则收不到消息，重则等于把入口对伪造请求敞开。]]></summary></entry><entry><title type="html">一个中文任务名引发的跨平台故障</title><link href="https://onlyforchris.github.io/blog/chinese-task-name-transport-failure/" rel="alternate" type="text/html" title="一个中文任务名引发的跨平台故障" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/chinese-task-name-transport-failure</id><content type="html" xml:base="https://onlyforchris.github.io/blog/chinese-task-name-transport-failure/"><![CDATA[<h1 id="一个中文任务名引发的跨平台故障">一个中文任务名引发的跨平台故障</h1>

<p>一个定时任务插件，到点本该正常触发一个 Agent 会话，却在任务名改成中文之后，整条链路报出一个让人摸不着头脑的错误：<code class="language-plaintext highlighter-rouge">TRANSPORT</code>。单看这个字段，大家第一反应都是“网络传输断了”，于是去查代理、查超时、查对端是否在线，查了半天一无所获——因为问题从头到尾就不是网络。</p>

<p>这篇文章记录我修复的一个真实故障：中文任务名是如何一路变成跨平台请求里的字节问题、最终被系统标成 <code class="language-plaintext highlighter-rouge">TRANSPORT</code> 的。细节以实际项目为准，但它揭示的“编码链路”问题在跨平台、跨字节编码的对接里非常典型。</p>

<h2 id="一现象报错叫-transport根因却在编码">一、现象：报错叫 TRANSPORT，根因却在编码</h2>

<p>故障表现为：创建定时任务时，任务标题是中文；到点触发时，宿主要求用“真实 Agent 会话”去执行 prompt。结果这些中文任务名被一路带进了会话标识里，在把请求发到模型服务时，被系统在请求层拦下，抛出一个被归类为 <code class="language-plaintext highlighter-rouge">TRANSPORT</code> 的错误。</p>

<p><code class="language-plaintext highlighter-rouge">TRANSPORT</code> 这个名字有极强的误导性——它让人以为问题出在“传输层”。但翻到真正抛错的地方，是对端在解析请求头时，遇到了一个非 ASCII 的字节序列，它按字节串（ByteString）去解析时失败了。换句话说：<strong>传输层根本没出问题，是请求里带了一个它无法接受的字节串。</strong></p>

<p>那个非 ASCII 的东西，就是中文任务名被写进会话 ID 之后留下的痕迹。</p>

<p>定位时我把 <code class="language-plaintext highlighter-rouge">TRANSPORT</code> 这个错误名先放一边，去翻了真正抛错的那一层日志，看到的是“请求头里有一个无法按字节串解析的值”。顺着这个值往上追，发现它来自会话 ID，而会话 ID 来自任务标题。整条链路就清楚了：<strong>标题（中文）→ 会话 ID（带中文）→ 请求头（字节串解析失败）→ 报成 <code class="language-plaintext highlighter-rouge">TRANSPORT</code>。</strong> 问题在“编码链路”，不在“传输链路”。</p>

<h2 id="二诱因把给人看的名字当成了内部-id">二、诱因：把“给人看的名字”当成了“内部 ID”</h2>

<p>问题的源头很隐蔽：实现里，任务有一个“标题”（给人看的中文），会话又有一个内部 <code class="language-plaintext highlighter-rouge">session id</code>。最省事的写法，是让两者共用同一个字段——标题是什么，session id 就是什么。这样界面好看、逻辑也简单。</p>

<p>但内部 ID 的用途和标题完全不同：它要进 URL、进请求头、进各种需要严格 ASCII 的协议边界。一旦塞进中文，就突破了这些边界对字节的限制。中文在 UTF-8 里是多字节的，请求头按 ASCII/ByteString 解析时就会炸。</p>

<p>所以根因不是“网络”，而是<strong>把一个展示字段错误地复用了成协议字段</strong>。展示名允许任意字符，内部标识必须被严格约束。</p>

<h2 id="三修复把展示名和内部-id彻底解耦">三、修复：把“展示名”和“内部 ID”彻底解耦</h2>

<p>正确的修法不是“以后别用中文”——那只是绕过，不是修复。这也是我在主页里写“没有停在‘改成英文绕过去’”的原因。绕过去，等哪天用户用了个别的特殊字符，故障会原样回来。</p>

<p>我的做法是：<strong>会话 ID 只允许 ASCII。</strong> 中文不再写入 session id，而是单独作为展示标题存在；内部标识由一套只含 ASCII 的规则生成，保证它无论进 URL、进请求头还是进其他协议边界都安全。</p>

<p>这一步的实质是：<strong>分清“给人看的字段”和“给机器用的字段”</strong>，让它们各自遵守自己的字符集约束，而不是共用同一个值去省事。展示层可以随便花哨，协议层必须保守。</p>

<h2 id="四回归测试把中文标题--ascii-session-id钉死">四、回归测试：把“中文标题 → ASCII session id”钉死</h2>

<p>光改代码不够，得把这条规则写进测试，防止以后谁又“顺手”把标题写回 ID。</p>

<p>这个项目有一条行为级 E2E，用的是假 host，无需完整的 DSH 运行时也能跑。覆盖项里就专门有一条：<strong>“中文标题 → ASCII session id”</strong>。也就是说，创建一个中文标题的任务，断言生成出来的内部会话 ID 是纯 ASCII，而不是中文原文。</p>

<p>这比口头约定可靠得多：只要有人把规则改回去，测试立刻变红，也就能在发布前拦住，而不是等用户踩到才报障。</p>

<h2 id="五遗留与兼容老数据也要给出路">五、遗留与兼容：老数据也要给出路</h2>

<p>修复还有一个绕不开的点：<strong>已经生成的旧会话，其 ID 里可能已经带了非 ASCII。</strong> 打开它们时，仍可能触发 <code class="language-plaintext highlighter-rouge">TRANSPORT</code>。如果没有兼容策略，用户会遇到“明明更新了，老任务还是坏”的困惑。</p>

<p>处理方式是：不强行改历史数据（改了会破坏会话绑定），而是在文档里明确说明——旧会话如果 ID 含非 ASCII，请用新版本重新「立即执行」生成新会话；同时把这条作为一个已知限制写清楚。这样用户拿到的是一个“知道该怎么处理”的版本，而不是一个“看起来修了其实暗坑还在”的版本。</p>

<h2 id="六可复用结论">六、可复用结论</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">TRANSPORT</code> 这类错误名会骗人：先看抛错位置的字节约束，别急着查网络。</li>
  <li>内部 ID 只允许 ASCII 是对协议边界的尊重；展示名另存，两者解耦。</li>
  <li>修复应针对“编码链路”，而不是“这次别用中文”这种绕行。</li>
  <li>用一条“中文输入 → ASCII 输出”的回归用例把规则钉住。</li>
  <li>老数据要给出迁出路径，并把已知限制写进文档，别让用户在暗坑里猜。</li>
</ul>

<p>一次跨平台故障，最后落在“字节字符串”上，而不是“网络”上——这本身就是个提醒：报错信息说“哪里出了问题”，未必是“真正出问题的地方”。顺着编码链路去看，比对着错误名去猜，要快得多。</p>]]></content><author><name>Chris</name></author><category term="编码" /><category term="定时任务" /><category term="Agent" /><category term="回归测试" /><summary type="html"><![CDATA[一个中文任务名引发的跨平台故障]]></summary></entry><entry><title type="html">为什么把 cron 放在宿主进程里，而不是独立服务</title><link href="https://onlyforchris.github.io/blog/cron-in-host-process/" rel="alternate" type="text/html" title="为什么把 cron 放在宿主进程里，而不是独立服务" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/cron-in-host-process</id><content type="html" xml:base="https://onlyforchris.github.io/blog/cron-in-host-process/"><![CDATA[<p>一提”定时任务”，很多人第一反应是”起一个调度服务”或者”上 Celery / 独立 cron”。”独立”听起来更干净：机器坏了不影响主程序，还能横向扩展。但有些场景，把定时器<strong>放进宿主进程</strong>反而更合适。</p>

<p>我在做一个 DSH 定时任务插件（<a href="https://github.com/onlyforchris/dsh-timer-agent">dsh-timer-agent</a>），它就是在<strong>宿主进程内做 cron 调度</strong>，到点驱动真实的 Agent 会话。这篇讲讲这个取舍。</p>

<h2 id="独立调度的代价往往被低估">独立调度的代价，往往被低估</h2>

<p>独立调度器看起来美好，实际会带来一堆和核心无关的复杂度：</p>

<ul>
  <li><strong>部署多一个组件</strong>：要单独起服务、开机自启、配置；</li>
  <li><strong>跨进程通信</strong>：调度器在 A 进程，Agent 运行时在 B 进程，两者之间要序列化传递任务、结果、上下文；这一层通信很容易出 bug，也很难调；</li>
  <li><strong>状态同步</strong>：调度的”有没有跑过”“跑到哪了”要跨进程同步；</li>
  <li><strong>环境不一致</strong>：调度器能拿到的东西，和运行时拿到的不一定一样，踩环境坑。</li>
</ul>

<p>如果你的定时任务就是要驱动<strong>同一个运行时里的 Agent 会话</strong>，那把这层通信拆到进程外，是把简单的事做复杂了。</p>

<h2 id="宿主内-cron-的好处">宿主内 cron 的好处</h2>

<p>在宿主进程里做 cron，最直接的好处是<strong>上下文共享</strong>：定时器到点，直接能拿到来驱动会话所需的一切——运行时、配置、会话对象。不需要序列化、不需要网络调用、不需要额外部署。代码更短，也更不容易坏。</p>

<p>而且它天然<strong>贴近真实场景</strong>：你调度的是这个运行时的 Agent，那就该在它身边调度，而不是隔着进程猜。</p>

<h2 id="那崩了怎么办这才是关键">那”崩了怎么办”？——这才是关键</h2>

<p>有人会问：宿主进程一崩，定时任务不就没了吗？对。这正是”宿主内 vs 独立”的核心权衡，也是你必须想清楚的点。</p>

<p>我的取舍是：接受”<strong>宿主进程存续期间</strong>“这个前置条件——定时任务的生命周期跟随宿主进程。够用，因为要驱动的是宿主内的会话；<strong>进程不在，会话本来也没了</strong>。</p>

<p>所以重点不是”怎么让它跨进程存活”，而是<strong>怎么让丢失可被感知</strong>。这里有个很实在的原则：<strong>“丢一次”和”重跑一遍”，要选一个并明确说明</strong>。</p>

<ul>
  <li>选<strong>丢一次</strong>：就要有<strong>可观测</strong>——任务失败、没跑、跑挂了，得能看到。我用了 Trace / 回执这类记录，进程内留下痕迹。</li>
  <li>选<strong>重跑一遍</strong>：就要保证<strong>幂等</strong>——重跑不会造成重复副作用，否则删了两次订单就麻烦了。</li>
</ul>

<p><strong>不声明、不观测的”丢了就丢”，是最危险的</strong>。</p>

<h2 id="踩过一个很脏的坑">踩过一个很”脏”的坑</h2>

<p>这个项目里有个挺典型的问题：<strong>中文任务名</strong>被错误地带进了跨平台的会话标识，导致请求头序列化失败、被误判成 <code class="language-plaintext highlighter-rouge">TRANSPORT</code> 错误。绕过去？不，要修根因——把<strong>展示名</strong>和<strong>用 ASCII 标识的会话 ID 解耦</strong>，并补回归测试（”中文标题 → ASCII session id”）。</p>

<p>这类问题很能说明”宿主内调度”的一个特质：<strong>它是和运行时长在一起的</strong>，所以运行时里任何一点脏数据，都可能被定时器放大成故障。定时器虽然是”宿主内”的，但它的健壮性和运行时是绑定的。</p>

<h2 id="最后几条">最后几条</h2>

<ul>
  <li>调度器放在宿主进程内，能<strong>省掉跨进程通信 + 额外部署</strong>，适合”就是要驱动本进程会话”的场景。</li>
  <li>接受”生命周期跟随宿主进程”这个前提，别硬做跨进程存活。</li>
  <li><strong>“丢一次”和”重跑一遍”必须二选一并声明</strong>；不声明的丢失最可怕。</li>
  <li>无论选哪个，都要有<strong>可观测</strong>（Trace/回执）和<strong>幂等</strong>（重跑无副作用）。</li>
  <li>中文任务名这类脏数据，会被定时器放大成故障——要修根因，加回归测试。</li>
</ul>

<p>架构取舍没有标准答案，但<strong>你得知道自己选了哪条路、放弃了什么、以及怎么兜底</strong>。把这几句话说清楚，比”哪个方案更高大上”重要得多。</p>]]></content><author><name>Chris</name></author><category term="定时任务" /><category term="架构取舍" /><category term="宿主进程" /><category term="可观测" /><summary type="html"><![CDATA[一提”定时任务”，很多人第一反应是”起一个调度服务”或者”上 Celery / 独立 cron”。”独立”听起来更干净：机器坏了不影响主程序，还能横向扩展。但有些场景，把定时器放进宿主进程反而更合适。]]></summary></entry><entry><title type="html">无网、麒麟 + ARM、自己家的大模型：一个”跑不起来很要命”的约束</title><link href="https://onlyforchris.github.io/blog/deployment-constraints/" rel="alternate" type="text/html" title="无网、麒麟 + ARM、自己家的大模型：一个”跑不起来很要命”的约束" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/deployment-constraints</id><content type="html" xml:base="https://onlyforchris.github.io/blog/deployment-constraints/"><![CDATA[<p>很多项目做砸，不是功能难，是<strong>连”跑在哪”都定不下来</strong>。一个项目如果要求：<strong>跑在麒麟上、ARM 架构、全程无网络、AI 用内部自己部署的模型</strong>——那它的复杂度会从一开始就跑偏。这篇聊聊这类”环境约束”会怎么影响整个项目。</p>

<h2 id="先搞清楚跑在哪再谈功能">先搞清楚”跑在哪”，再谈功能</h2>

<p>这类项目的头号约束是<strong>环境</strong>。甲方一句话：系统要跑在<strong>麒麟操作系统 + ARM 架构</strong>上；整个开发是<strong>无网络</strong>的，你不能开机就去 <code class="language-plaintext highlighter-rouge">npm install</code>、去拉公网包、去查文档；调用的 AI 也不是公网 API，而是<strong>内部部署的大模型</strong>。</p>

<p>这些东西看着是”最后打包的事”，其实<strong>在最早就决定了你后面每一步怎么走</strong>。所以做这种项目，第一件事不是想功能，是<strong>把环境摸清楚</strong>：什么系统、什么架构、能不能上网、依赖从哪来、模型从哪来。</p>

<h2 id="依赖是最先被卡住的">依赖，是最先被卡住的</h2>

<p>无网环境里，”装个包”会变成一件很别扭的事：</p>

<ul>
  <li>你不能指望 <code class="language-plaintext highlighter-rouge">npm install</code> / <code class="language-plaintext highlighter-rouge">pip install</code> 从公网拉。要么<strong>提前把依赖打进内网仓库 / 离线镜像</strong>，要么把包一个个拷进来。</li>
  <li><strong>版本要锁死</strong>，因为你不能”随时升一升”，升了可能就拉不到了。</li>
  <li>有些中间件、原生库对 <strong>ARM 架构支持不好</strong>，你得找替代、或者自己交叉编译。这一步如果没提前验证，会在后端炸成大坑。</li>
</ul>

<p>所以这类项目里，”依赖管理”不是一个流程问题，而是<strong>能不能开工</strong>的问题。</p>

<h2 id="arm--麒麟镜像和原生库的适配">ARM + 麒麟，镜像和原生库的适配</h2>

<p>到了部署环节，<strong>架构适配</strong>是实打实的活：</p>

<ul>
  <li>镜像要编成 <strong>ARM 平台</strong>的，不是 x86 直接拿过来就能跑。</li>
  <li>一些底层依赖、驱动、原生库，在 ARM/麒麟上可能有兼容问题，需要重新验证、换版本、甚至自己改。</li>
  <li>有的组件你本地（x86）跑得好好的，一上 ARM/麒麟就出问题，<strong>调试环境还不一样</strong>，很难复现。</li>
</ul>

<p>这一步最容易被低估：本地能跑 ≠ 目标环境能跑。<strong>你不提前在目标环境试一把，最后大概率是”上线才发现跑不起来”。</strong></p>

<h2 id="ai-模型自己部署还是接别人的">AI 模型，自己部署还是接别人的</h2>

<p>这类项目往往要求”用内部 AI”，这就引出一个很现实的问题：<strong>模型从哪来、算力谁出。</strong></p>

<ul>
  <li>如果要求<strong>自己部署到可用级别</strong>，成本是很高的——动辄几十万的算力投入，还不一定能调到满意的效果。</li>
  <li>现实中往往是<strong>别人（比如某高校或平台）已经部署好了</strong>，你只需要<strong>接</strong>它、把它的接口接进你的系统。</li>
  <li>但这么一来，你又要面对：<strong>模型好坏、响应快慢、可用性，都不在你控制范围内</strong>。你只能保证”我的系统接得对”，保证不了”模型本身好不好”。</li>
</ul>

<p>所以”AI 落地”在这个场景下，往往不是”我怎么做一个聪明的 agent”，而是”<strong>我怎么把一个别人部署好的模型，可靠地接进我自己的系统</strong>“。</p>

<h2 id="无网联调最难受的一段">无网联调，最难受的一段</h2>

<p>无网环境下，<strong>你没法在真实环境里连着真实数据跑来跑去</strong>，只能靠模拟、靠假数据、靠”先虚拟地跑通”。等真正拿到真实环境，往往才发现一堆没对上的问题。</p>

<p>这跟前面那个”联调时发现字段对不上、只能靠随机数填”是同一个伤口：<strong>你没在真实环境里验证过，就永远不知道它通不通。</strong></p>

<h2 id="这类项目最值得记住的一句">这类项目最值得记住的一句</h2>

<p><strong>“环境约束”不是”部署阶段的一次性工作”，它从头到尾都在约束你。</strong></p>

<ul>
  <li>依赖：能不能装、锁不锁版本，第一步就定死。</li>
  <li>架构：ARM/麒麟的兼容性，不提前验证就是定时炸弹。</li>
  <li>模型：自己部署还是接别人的，成本和控制力差很远。</li>
  <li>联调：无网 + 假数据，让你没法在真实环境里确认”真的通”。</li>
</ul>

<p>所以拿到这样一个项目，<strong>先把”跑在哪、能不能上网、模型从哪来”这几件事问清楚，再开始写第一行代码。</strong> 不然你以为自己做的是功能，其实大半精力会被环境拖在泥里。</p>]]></content><author><name>Chris</name></author><category term="部署" /><category term="麒麟" /><category term="ARM" /><category term="内网" /><category term="约束" /><summary type="html"><![CDATA[很多项目做砸，不是功能难，是连”跑在哪”都定不下来。一个项目如果要求：跑在麒麟上、ARM 架构、全程无网络、AI 用内部自己部署的模型——那它的复杂度会从一开始就跑偏。这篇聊聊这类”环境约束”会怎么影响整个项目。]]></summary></entry><entry><title type="html">中文技术标书的”五段式”，和”强承诺一定要补边界”</title><link href="https://onlyforchris.github.io/blog/five-part-bid-and-boundaries/" rel="alternate" type="text/html" title="中文技术标书的”五段式”，和”强承诺一定要补边界”" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/five-part-bid-and-boundaries</id><content type="html" xml:base="https://onlyforchris.github.io/blog/five-part-bid-and-boundaries/"><![CDATA[<p>写技术标书，最怕的不是”写不满”，而是<strong>写得满但站不住</strong>。字越多，越容易被评审抓到一个经不起推敲的承诺，然后整段的可信度都跟着塌。</p>

<p>我在做一个用来生成、审查、规范化中文技术标书与应标材料的 Skill（<a href="https://github.com/onlyforchris/bid-response-production">bid-response-production</a>）。里面最有用的一条规则不是”多写点”，而是<strong>“强承诺一定要补边界”</strong>。这篇文章讲讲这个结构怎么定的。</p>

<h2 id="先定结构五段式">先定结构：五段式</h2>

<p>标书是不是越长越好？不是。评审看的是”你这条标书有没有把该说的说清楚”。我把它收敛成<strong>五段式</strong>，每一段解决一类问题：</p>

<ol>
  <li><strong>功能实现</strong>：你要交付的东西，怎么实现、覆盖哪些场景；</li>
  <li><strong>技术要点</strong>：关键技术点，以及你采用的技术路线；</li>
  <li><strong>业务联动</strong>：它怎么和客户的业务流程、已有系统衔接；</li>
  <li><strong>差异化能力</strong>：你和别人不一样的地方，凭什么选你；</li>
  <li><strong>验收·证据口径</strong>：怎么证明你做到了，用什么口径验收。</li>
</ol>

<p>每段都有明确目的，评审顺着这个结构能快速抓住重点，而不是在冗长的叙述里找线索。</p>

<p>开头还要有一段<strong>“标书专属段”</strong>——不是把产品介绍复制过来，而是针对这份标书的 800–1200 字：点题、讲清你对应标需求的思路。这段是”钩子”，决定了评审愿不愿意往下看。</p>

<h2 id="为什么结构比字数重要">为什么结构比字数重要</h2>

<p>没有结构的标书，容易变成”功能清单 + 套话”。而一旦有结构，就逼着你在每个位置写对内容：功能段写实现，技术段写路线，差异段写亮点，验收段写口径。</p>

<p>默认的排版也服务于这个目的：正文<strong>宋体、小四、1.5 倍行距</strong>，目录清晰、章节分明。一页页翻过去，评审能很快定位到”他要的答案在哪一段”。</p>

<h2 id="强承诺要补边界">强承诺要补边界</h2>

<p>这是我最想强调的一点。写标书的人容易犯的毛病是<strong>把话说满</strong>：</p>

<blockquote>
  <p>“我们承诺 100% 保证系统 7×24 小时稳定运行，并提供无条件售后。”</p>
</blockquote>

<p>这种话看着有诚意，实则是<strong>踩雷</strong>。因为它是一个没有边界的承诺——真实世界里没有”100% 无条件”的东西，评审一旦追问细节，这句漂亮话就成软肋。</p>

<p>正确的做法是：<strong>承诺的同时，把边界一起说清楚</strong>：</p>

<blockquote>
  <p>“我们承诺核心服务可用性 <strong>≥ 99.9%</strong>，并配套完整的告警与应急切换；对于非归因于本方的第三方故障，按合同约定承担责任。”</p>
</blockquote>

<p>一个承诺 + 一个边界，比一个”满话”可信得多。<strong>敢说边界，反而显得专业</strong>；只会堆满话，显得心虚。</p>

<p>这套处理我直接做成了规则：<code class="language-plaintext highlighter-rouge">review_bid_docx.py</code> 会去<strong>审查</strong>生成出来的标书，凡是出现”无条件、100%、完全保证、绝对”这类<strong>无限承诺</strong>的词，就提示你要么加边界、要么改成可度量的指标。生成端 <code class="language-plaintext highlighter-rouge">md_to_bid_docx.py</code> 也按这个结构产出，从源头减少”满话”。</p>

<h2 id="可度量的承诺才有讨论价值">可度量的承诺，才有讨论价值</h2>

<p>把”一定稳定”改成”≥ 99.9%”，把”随时响应”改成”≤ 30 分钟告警、≤ 2 小时响应”，把”无条件支持”改成”明确支持范围与除外情形”。</p>

<p>承诺一旦<strong>可度量</strong>，评审就没法用”你凭什么说 100%”来攻击你；反而会去讨论”99.9% 的保障机制是什么”，这才是你想被问到的问题。</p>

<h2 id="最后几条">最后几条</h2>

<ul>
  <li>标书结构比字数重要：功能 / 技术 / 业务联动 / 差异 / 验收口径，五段各司其职。</li>
  <li>开头要有针对本标的 800–1200 字专属段，别拿产品介绍凑。</li>
  <li><strong>强承诺一定要补边界</strong>；把”满话”改成”可度量的承诺 + 边界”。</li>
  <li>用工具做生成 + 审查闭环：能生成，也要能揪出无限承诺。</li>
  <li>排版服务于阅读：宋体小四、1.5 倍行距、清晰目录。</li>
</ul>

<p>能经得起追问的标书，才是好标书。而”经得起追问”，靠的不是字数，是<strong>每一句承诺都站得住</strong>。</p>]]></content><author><name>Chris</name></author><category term="投标" /><category term="标书生成" /><category term="文档自动化" /><category term="承诺与边界" /><summary type="html"><![CDATA[写技术标书，最怕的不是”写不满”，而是写得满但站不住。字越多，越容易被评审抓到一个经不起推敲的承诺，然后整段的可信度都跟着塌。]]></summary></entry><entry><title type="html">别再用 PPT 做汇报了：技术内容用这套 HTML 幻灯片</title><link href="https://onlyforchris.github.io/blog/html-slides-over-ppt/" rel="alternate" type="text/html" title="别再用 PPT 做汇报了：技术内容用这套 HTML 幻灯片" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/html-slides-over-ppt</id><content type="html" xml:base="https://onlyforchris.github.io/blog/html-slides-over-ppt/"><![CDATA[<p>先声明：我不是说 PPT 一无是处。它是做商务汇报、讲故事、给非技术领导看的<strong>好工具</strong>。但当你在讲<strong>技术、代码、怎么排查问题</strong>的时候，PPT 往往是<strong>最差的那个选择</strong>。这篇想说说为什么，以及能用什么替代。</p>

<h2 id="ppt-做技术内容的三个天生劣势">PPT 做技术内容的三个天生劣势</h2>

<p><strong>1. 代码进去就”死”了</strong>
技术分享最重要的料是<strong>代码</strong>。可代码一进 PPT，就变成了”截图”或”高亮文本”：不能复制、不能点、一页的宽度也塞不下多少行，经常被压缩到看不清。你讲得越细，PPT 越像在”演”，而不是在”讲清楚”。</p>

<p><strong>2. 它是二进制，改不动也看不出来</strong>
PPT 文件是二进制，commit 到 git 里，diff 基本是”看不出改了什么”。你改了哪一页、改了哪句话，<strong>完全不可追踪</strong>。谁改的、改了什么、能不能回滚，全是笔糊涂账。</p>

<p><strong>3. 看的人要先装软件、挑环境</strong>
要发给别人看，得想办法导出 PDF、发文件、确认对方能打开。手机上还常常排得乱七八糟。你跟人 say”你打开这个链接就能看”，PPT 做不到。</p>

<h2 id="html-幻灯片正好补上这三点">HTML 幻灯片正好补上这三点</h2>

<p>我博客里现在有一套放映器（<a href="/blog/slideshows/">演示目录</a>），就是用 HTML/Markdown 写一套可放映的东西。它天然解决上面几个问题：</p>

<p><strong>① 代码是”活”的</strong>
代码就是页面里的文本，<strong>能复制、能缩放、格式不失真</strong>。你还可以直接在页面上做交互，讲起来是”真的给你看代码”，不是”给你看一张代码的图”。</p>

<p><strong>② 它是文本，能进 git</strong>
一套幻灯片就是一个 Markdown/HTML 文件，<strong>git 能审 diff、能回溯、能回滚</strong>。谁改的、改了什么，一目了然。它跟你的代码<strong>住在一起</strong>，长期维护不成问题。</p>

<p><strong>③ 一个链接就能看</strong>
一个 URL，不用装 Office，手机电脑都能看，还能<strong>全屏、明暗切换、目录跳页、自动播放</strong>。发出去就是”点这个链接”。</p>

<h2 id="这套放映器能做什么">这套放映器能做什么</h2>

<p>我做的这套，常用功能都齐了：</p>

<ul>
  <li>封面开始、键盘/按钮翻页、<strong>目录跳页</strong>（当前页高亮）</li>
  <li><strong>自动播放</strong>（适合放一遍给别人看）、全屏、明暗切换</li>
  <li>底部页码 + 顶部进度条</li>
  <li>页面自动适配屏幕，手机也能全屏看</li>
</ul>

<p>写它的成本也低：一个 <code class="language-plaintext highlighter-rouge">layout: slideshow</code> 页面 + 一段段 <code class="language-plaintext highlighter-rouge">&lt;section class="slide"&gt;</code>，目录还能自动从每页的标题生成。详见<a href="/blog/write-html-slides/">教程</a>。</p>

<h2 id="那-ppt-还剩什么用">那 PPT 还剩什么用</h2>

<ul>
  <li><strong>对外商务汇报</strong>：要讲故事、要统一的视觉模板、要给非技术的人”感觉专业”，PPT 依然顺手。</li>
  <li><strong>需要复杂动画/媒体/设计</strong>：一个精心排版的静态页面，确实比得上 PPT 的华丽转场。</li>
  <li><strong>就是喜欢用</strong>：工具服务于人，不是非得换。</li>
</ul>

<h2 id="我的务实建议">我的务实建议</h2>

<p><strong>看场景，别跟风。</strong></p>

<ul>
  <li>讲<strong>技术、代码、排查、架构</strong> → <strong>HTML / Markdown 幻灯片</strong>，能进 git、代码活、一个链接就能看。</li>
  <li>讲<strong>商务、方案、给领导/客户</strong> → <strong>PPT</strong>，讲感觉、讲故事、要模板。</li>
</ul>

<p>技术人最常犯的错，是把技术汇报做成了商务 PPT：一堆动画、一张截图代码、讲完什么都没留下。<strong>代码该讲的没讲清楚，反而被”包装”稀释了。</strong></p>

<h2 id="一句话">一句话</h2>

<p>PPT 是”给你看”的。HTML 幻灯片是”<strong>讲清楚 + 能留下 + 能维护</strong>“的。技术分享，我更愿意用后面这个。</p>

<p>下次轮到你讲代码，不妨试试把你那些”方案/复盘/教学”做成一套能放映的 HTML 幻灯片。点开就能讲，讲完还能留在仓库里。</p>]]></content><author><name>Chris</name></author><category term="幻灯片" /><category term="汇报" /><category term="工具" /><category term="工程" /><summary type="html"><![CDATA[先声明：我不是说 PPT 一无是处。它是做商务汇报、讲故事、给非技术领导看的好工具。但当你在讲技术、代码、怎么排查问题的时候，PPT 往往是最差的那个选择。这篇想说说为什么，以及能用什么替代。]]></summary></entry><entry><title type="html">幂等：IM webhook 重试时，怎么保证不重复处理</title><link href="https://onlyforchris.github.io/blog/idempotency-for-webhook-retries/" rel="alternate" type="text/html" title="幂等：IM webhook 重试时，怎么保证不重复处理" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/idempotency-for-webhook-retries</id><content type="html" xml:base="https://onlyforchris.github.io/blog/idempotency-for-webhook-retries/"><![CDATA[<p>接入 IM 机器人后，你几乎一定会遇到这个问题：<strong>同一个事件，被推了两次。</strong> 渠道为了保证”别丢消息”，往往选择<strong>至少一次投递</strong>——也就是说，你的 webhook 可能因为超时、网络抖动、渠道侧重试，而收到<strong>同一个事件的重复回调</strong>。</p>

<p>这时候如果你不做处理，就会重复回复、重复建会话、重复触发动作，用户收到两遍消息，或者后台跑了两次同一件事。这篇文章讲我把”去重”落在哪。</p>

<h2 id="先接受一个前提默认至少一次">先接受一个前提：默认至少一次</h2>

<p>别指望渠道”只推一次”。”至少一次”是很多消息系统的默认，你要做的不是祈祷不重，而是<strong>让重复变得无害</strong>。</p>

<p>所以第一件事是：<strong>把”可能重复”当成常态来设计</strong>。读回调的代码，默认就是”这可能是我见过的，也可能是新的”。</p>

<h2 id="用什么做幂等键">用什么做幂等键</h2>

<p>去重的核心是<strong>幂等键</strong>——每一条消息/事件，都用一个<strong>稳定、唯一</strong>的标识来代表”这是我”。常见来源：</p>

<ul>
  <li><strong>渠道给的事件/消息 ID</strong>：比如 <code class="language-plaintext highlighter-rouge">message_id</code>、<code class="language-plaintext highlighter-rouge">event_id</code>、<code class="language-plaintext highlighter-rouge">msgId</code>。这是最可靠的，因为它代表”渠道眼里这一条是什么”；</li>
  <li><strong>会话 + 时间戳</strong>：如果渠道不给稳定 ID，只能用（会话标识, 时间）去凑，但这样会有撞键风险；</li>
  <li><strong>自己生成</strong>：如果上面都没有，就得用回调里的内容去算一个指纹，但要小心不同渠道的内容结构不一样。</li>
</ul>

<p>关键是<strong>用渠道自己给的 ID</strong>，别自己发明。因为渠道重试时，会带着<strong>同一个 ID</strong> 回来，你才能认出”这条我见过”。</p>

<h2 id="去重放哪里先查再写还是写时判重">去重放哪里：先查再写，还是写时判重</h2>

<p>有两种做法：</p>

<ol>
  <li><strong>先查再写</strong>：收到回调，查一下这个 ID 处理过没有，处理过就跳过。简单，但有两个坑——并发下两个人同时查到”没处理过”，都会执行；以及”查和写”不是原子的。</li>
  <li><strong>写时判重（用唯一约束）</strong>：在存储层把一个字段设为<strong>唯一</strong>，处理前先写入这个 ID；如果插入冲突，说明已经处理过了，直接跳过。这个更稳，因为<strong>唯一约束在数据库层面保证了原子性</strong>。</li>
</ol>

<p>我更推荐<strong>第 2 种</strong>：用一个唯一约束兜底，而不是只靠”先查”。单机下可以用内存 set，但要考虑进程重启会丢；有一个持久化、带唯一约束的地方（比如 SQLite 的唯一索引），才真正靠得住。</p>

<h2 id="别忘了去重之外的两个点">别忘了去重之外的两个点</h2>

<ul>
  <li><strong>顺序</strong>：重试不一定是按顺序来的。如果业务对顺序敏感（比如”先建会话再发消息”），要去重之外还要处理乱序，否则可能”先处理了后面的”。</li>
  <li><strong>可观测</strong>：你要能看到”到底处理了多少条、重复了多少条、跳过了多少条”。因为如果渠道疯狂重试，说明你的处理有 bug，或者超时了。<strong>重复率陡增，往往是你处理慢或坏掉的信号</strong>。</li>
</ul>

<h2 id="最后几条">最后几条</h2>

<ul>
  <li>默认”至少一次投递”，把重复当常态设计。</li>
  <li>用<strong>渠道给的稳定 ID</strong> 做幂等键，别自己发明。</li>
  <li>用<strong>唯一约束 + 写时判重</strong>兜底，别只靠”先查再写”。</li>
  <li>关注<strong>顺序</strong>和<strong>可观测</strong>：重复率上升 = 有 bug 或慢了。</li>
  <li>目标不是”绝对不重”，而是<strong>“重了也没事”</strong>。</li>
</ul>

<p>把幂等做好，你就能坦然面对渠道的每一次重试，而不用提心吊胆。</p>]]></content><author><name>Chris</name></author><category term="幂等" /><category term="消息处理" /><category term="webhook" /><category term="可观测" /><summary type="html"><![CDATA[接入 IM 机器人后，你几乎一定会遇到这个问题：同一个事件，被推了两次。 渠道为了保证”别丢消息”，往往选择至少一次投递——也就是说，你的 webhook 可能因为超时、网络抖动、渠道侧重试，而收到同一个事件的重复回调。]]></summary></entry><entry><title type="html">一个本地控制面的边界：MCP 工具该暴露多细</title><link href="https://onlyforchris.github.io/blog/local-control-plane-boundaries/" rel="alternate" type="text/html" title="一个本地控制面的边界：MCP 工具该暴露多细" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://onlyforchris.github.io/blog/local-control-plane-boundaries</id><content type="html" xml:base="https://onlyforchris.github.io/blog/local-control-plane-boundaries/"><![CDATA[<p>做 Agent 平台的人，很容易一上来就想做成”多租户、云上、带鉴权”的大系统。但很多场景其实只需要一个<strong>本地控制面</strong>：在一台机器上，把工作区、运行时、MCP 面做扎实。</p>

<p>我在做一个本地优先的企业 Agent 平台（<a href="https://github.com/onlyforchris/agent-hub">agent-hub</a>），第一版故意做得很小：Python 控制面 + 浏览器界面 + SQLite 存储 + 原生 MCP 面。这一版的目的不是”功能多”，而是<strong>把边界和连接方式先验证清楚</strong>。这篇讲两个我在意的边界：该不该上多租户，以及 MCP 工具该暴露多细。</p>

<h2 id="先定边界本地单机无鉴权">先定边界：本地、单机、无鉴权</h2>

<p>第一版默认<strong>只绑定 127.0.0.1</strong>，没有鉴权，也不是生产多租户服务。听起来是不是”太简陋”？其实这是故意的。</p>

<p>在没有明确多租户需求之前，硬上鉴权、租户隔离、云部署，会带来一堆和核心价值无关的复杂度。而一个本地控制面的核心价值是：<strong>让 Agent 能在一个真实、可运行的本地环境里组织工作区、有 MCP 可接入</strong>。先把这条链路跑通，比先做隔离重要得多。</p>

<p>所以我用一个显式开关来控制边界：默认拒绝远程绑定，只有显式设置 <code class="language-plaintext highlighter-rouge">AGENT_HUB_ALLOW_REMOTE=1</code> 才允许远程。这等于把”要不要对外开放”这个决定，从代码层面<strong>逼着你去主动表态</strong>，而不是默认就对所有人开放。</p>

<h2 id="mcp-工具太多没用太少不够">MCP 工具：太多没用，太少不够</h2>

<p>MCP 面是给 Agent 运行时接入用的 <code class="language-plaintext highlighter-rouge">http://127.0.0.1:8765/mcp</code>。第一版我只放了三个工具：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">agenthub_status</code> —— 查整体状态；</li>
  <li><code class="language-plaintext highlighter-rouge">workspace_list</code> —— 列工作区；</li>
  <li><code class="language-plaintext highlighter-rouge">workspace_create</code> —— 创建工作区。</li>
</ul>

<p>看着是不是”太少了”？但这是有意控制的。</p>

<p>工具太细，Agent 会被细节淹没：一堆零碎的工具，调用成本高、参数难对齐、Agent 容易选错。工具太少又会不够用，连基本操作都要绕。</p>

<p>判断粒度对不对，我有个朴素标准：<strong>这个工具是不是对应一个 Agent 能理解、能直接完成的”业务动作”</strong>。</p>

<ul>
  <li>“列工作区” —— 是一个完整动作，Agent 一看就懂；</li>
  <li>“创建工作区” —— 是一个动作，给了明确的输入（名称等）就能做；</li>
  <li>而不应该把它拆成”获取连接”“设置名称”“触发生成”这种更底层的碎片。</li>
</ul>

<p>换句话说，<strong>工具应该对应”意图”，而不是对应”内部函数”</strong>。内部实现怎么拆，是控制面自己的事；对外暴露的，是 Agent 能直接用的动作。</p>

<h2 id="边界清晰的代价与收获">边界清晰的代价与收获</h2>

<ul>
  <li><strong>代价</strong>：第一版做不了多租户、云部署、细粒度权限；要多加功能就得重新设计。</li>
  <li><strong>收获</strong>：核心链路简单稳定，MCP 面小而清晰，Agent 不会在一堆工具里迷路；边界用显式开关控制，风险可控。</li>
</ul>

<p>等真实的”多租户 / 远程”需求出现，再往上叠加，而不是提前为想象的需求买单。很多系统是被”万一以后用得上”拖垮的。</p>

<h2 id="最后几条">最后几条</h2>

<ul>
  <li>先验证部署形态和连接边界，再叠加编排；第一版故意小。</li>
  <li>默认只绑本地、无鉴权，用显式开关（<code class="language-plaintext highlighter-rouge">AGENT_HUB_ALLOW_REMOTE</code>）决定是否对外。</li>
  <li>MCP 工具对应<strong>业务动作</strong>，别对应内部函数；少而清晰 &gt; 多而杂。</li>
  <li>别急着上多租户：没有真实需求，复杂度就是纯粹的负担。</li>
  <li>数据默认本地（SQLite），可运行、可验证，比堆功能更能说明问题。</li>
</ul>

<p>一个”边界清楚、能跑起来”的本地控制面，比一个”什么都想装、却处处没做实”的大平台有用得多。</p>]]></content><author><name>Chris</name></author><category term="MCP" /><category term="本地优先" /><category term="控制面" /><category term="边界" /><summary type="html"><![CDATA[做 Agent 平台的人，很容易一上来就想做成”多租户、云上、带鉴权”的大系统。但很多场景其实只需要一个本地控制面：在一台机器上，把工作区、运行时、MCP 面做扎实。]]></summary></entry></feed>