[{"content":"之前我用 Java + Spring AI 做过 RAG 知识库和销售数据 Agent。它们主要是文本交互：用户提问，Agent 选择工具，整理结果并返回答案。做完之后，我想再尝试一个更接近连续业务对话的场景——用户不需要一次说清楚需求，可以边说边修改条件，系统还得记得前面发生了什么。\n于是有了 Voice Shopping：用户对着麦克风说“我想买双跑鞋，预算 500，膝盖不太好”，系统识别需求、追问缺少的信息、筛选商品，再用语音解释推荐理由。难点在于，用户说话不会按表单来：有时漏掉条件，有时临时改主意。LLM 要听懂这些变化，程序也得知道接下来该追问、推荐，还是确认订单。\n一、为什么从单 Agent 开始拆分 一开始，我给单 Agent 挂上意图识别、澄清、商品搜索、重排、推荐理由、情感润色等工具，让它自己规划调用顺序。但对话稍微复杂一点，调用顺序和上下文就容易出问题。\n例如用户只说“买双跑鞋”，模型有时会跳过追问直接检索。把分类规则和推荐要求塞进同一份 Prompt 后，生成的推荐内容里还混入过意图识别用的 JSON 字段。换成语音交互就更麻烦了：如果需求还没问清楚就播报推荐，后面再纠正也很别扭。所以，什么时候开始播报，也得由流程控制。\n拆分也有成本：每增加一个 Agent，就要多维护一份上下文和调用关系。预算比较、价格过滤、订单状态转移这些确定性的工作，交给普通业务代码更直接。\n最后，我把它拆成了四个 Worker Agent，再用一个 Java 编写的 Orchestrator 控制调用顺序。\n二、四个 Worker 的分工 先看几句导购对话，就能看出每一步要处理的问题不太一样：\n用户表达 真正需要处理的事 “我想买双跑鞋，预算 500，膝盖不太好。” 识别购买意图，提取品类、预算和用途偏好 “平时在水泥路上跑。” 合并新的使用场景，判断条件是否足够 “再便宜点的有吗？” 理解对上一轮推荐的调整，而不是重新开始聊天 “刚才那双 Nike，想换软一点的。” 找到上下文商品，保留有用条件，更新偏好 “就第二款吧。” 解析推荐列表中的商品引用，进入订单处理 对应到实现中，四个 Worker 是这样分工的。\nIntentAgent 先判断用户想做什么，用轻量模型 qwen-turbo 做意图分类和槽位提取，输出结构化结果。系统定义了六类意图：PRODUCT_RECOMMENDATION、CLARIFY_NEEDED、PRODUCT_COMPARE、CHITCHAT、ORDER_CONFIRM 和 OUT_OF_SCOPE。闲聊单独走回复分支，越权请求则走拒绝分支。\n缺少信息时，就轮到 ClarifyAgent。规则先检查必填条件，常见的缺失字段用模板追问，复杂表达和长尾品类再交给模型辅助生成问题。比如缺少使用场景，用户听到的应该是“平时在哪儿跑步”，而不是“请补充 scenario 字段”。\nRecommendAgent 负责商品推荐，通过业务 Service 完成向量召回、属性过滤和画像重排，输出包含 productId、name、price、attributes、matchScore 的结构化结果。数据库查询、权限和筛选规则都放在 Service 中。最初这里还会调用模型生成推荐理由，后来我把理由生成合并到了 EmotionAgent，第六节会展开说。\n最后由 EmotionAgent 把结果说给用户听。它接收推荐结果、用户原话和会话情绪，组织成适合 TTS 的短句。用户犹豫时，多解释一下推荐原因；用户嫌贵时，先回应价格顾虑。调整后的流程里，推荐理由和口语表达都在这一步完成，不再额外查询商品。\n每个 Worker 有自己的 Prompt、Memory 和输入输出格式。Orchestrator 根据当前情况决定调用谁：闲聊直接回复，信息不足先追问，条件齐全后再推荐。\n三、用 Java 状态机控制导购流程 整体上，我采用 Java 21、Spring Boot 4 和 AgentScope Java Starter。前端通过 H5 + WebAudio 采集音频，WebSocket 承载双向语音与控制事件；Paraformer 负责实时 ASR，CosyVoice 负责 TTS。AgentScope 用来组织 Worker、各自的上下文，并通过 MsgHub 和生命周期 Hook 支持协作与调用管理。\n一次语音请求大致会经过这些步骤：\n浏览器（H5 / WebAudio） │ 16 kHz PCM / WebSocket ▼ Paraformer 实时 ASR │ 识别后的用户文本 ▼ IntentAgent：意图与槽位 │ IntentResult ▼ Orchestrator：Java 状态机 + SessionState ├─ 推荐意图 ──\u0026gt; 规则判断 / ClarifyAgent │ │ 条件已足够 │ ▼ │ RecommendAgent │ （经 Service 检索、过滤、重排） │ │ │ ▼ │ EmotionAgent → 合规检查 → TTS ├─ 闲聊 ───────────────\u0026gt; EmotionAgent │ └─→ 合规检查 → TTS ├─ 订单确认 ───────────\u0026gt; 订单业务 Service └─ 越权请求 ───────────\u0026gt; 固定拒绝模板 → TTS 合规检查：ComplianceChecker；语音合成：CosyVoice 辅助层：Redis 会话记忆 / PostgreSQL + pgvector / Agent 内部 Memory 这里的 Orchestrator 是一个 Service 级状态机。IntentAgent 用模型识别意图，Orchestrator 再依据 intent 和当前 phase，选择推荐、闲聊、确认或拒绝分支。\n主要路由可以用下面的流程伪代码表示：\nPRODUCT_RECOMMENDATION → 检查必填槽位 → 推荐链路 → 回复 → 合规 → TTS CHITCHAT → 情感回复 → 合规 → TTS ORDER_CONFIRM → 订单确认分支 OUT_OF_SCOPE → 固定拒绝文案 → TTS 另有 CLARIFY_NEEDED 和 PRODUCT_COMPARE 等分支， 会结合当前会话状态处理补充条件与商品对比。 这些分支的条件比较明确，直接写成代码就能处理，也省去了一次让模型决定路由的调用。如果走错了分支，可以沿着 intent、phase 和判断条件往回查。必填条件和业务权限也集中在程序里检查，修改规则时更容易找到位置。\n比如用户说“把所有商品价格降 50%”，IntentAgent 将其识别为 OUT_OF_SCOPE 后，程序直接返回固定拒绝文案，这个分支就结束了。\n四、意图串场之后，怎么管理会话记忆 我遇到过一次意图串场：先说“推荐跑鞋”，再说“换个颜色”，意图模型受到上一轮 category=跑鞋 的历史影响，把当前请求重新识别成商品推荐，结果又走了一遍检索。\n排查时，需要先弄清楚历史信息存在哪里。项目里有三层记忆：\n层级 主要职责 载体 Agent 内部记忆 供单个 Worker 多次调用时使用 AgentScope InMemoryMemory 会话短期记忆 共享近期对话摘要与业务上下文 Redis 跨会话长期记忆 保存用户品牌、品类、消费偏好等画像 PostgreSQL AgentFactory 按 sessionId 复用 Agent 实例，内部 Memory 会随多轮调用累积。我在 AgentMemoryPolicy 中给不同 Agent 设置了不同的保留规则：\n意图 Agent 每轮清空内部 Memory，再注入最近三轮的摘要，减少旧分类结果的干扰。 推荐 Agent 保留最近四轮（八条消息），用于理解“刚才那双”这类表达。 情感 Agent 保留二十轮（四十条消息），用较长的上下文观察语气变化。 预算、推荐顺序这些业务信息则单独存放，由 SessionStateService 等服务管理，包括当前 phase、已经确定的预算、上一次推荐的商品顺序和等待确认的操作。用户说“第二款”时，系统按保存的推荐顺序查找对应商品。\n五、从需求追问到商品筛选 拆开 Agent 之后，还有一个问题：每个步骤里，到底有多少工作需要模型来做？澄清和推荐是两个比较典型的例子。\n缺什么信息，就追问什么 澄清流程采用“规则优先，LLM 兜底”的方式。不同品类预设不同必填槽位：例如跑鞋需要 category、budget、scenario、runningFrequency；手表重点关心 category、budget、occasion。这组字段属于业务约定，不需要每轮重新问模型。\n用户说“我想买双跑鞋” ↓ 按品类检查必填槽位 ↓ 缺少 budget / scenario 等条件？ ├─ 否：进入推荐 └─ 是：优先使用追问模板 ↓ 复杂表达或长尾品类 ↓ 再由 LLM 生成追问 例如缺少场景，模板可以直接问“平时跑塑胶跑道、水泥路，还是都有？”常见品类按预设字段检查就够了；遇到规则覆盖不到的品类，再由 LLM 辅助判断信息是否充分、该怎么追问。\n先筛选商品，再生成推荐理由 如果用户说“预算 500，希望缓震好一点”，纯向量检索也许会召回描述中出现了“缓震科技”的竞速跑鞋，但商品的实际缓震属性未必符合需求。语义相似不等于适合购买。\n推荐链路分为四段：\n需求槽位 ↓ ① pgvector + HNSW：语义召回 Top 20 ↓ ② JSONB / SQL：预算、缓震属性等硬过滤 ↓ ③ ProfileReranker：品牌偏好、历史购买、预算敏感度加权 ↓ 取 Top 3 候选 ↓ ④ EmotionAgent：合并生成推荐理由与口语回复 向量检索先找出描述相关的商品，再用价格和属性筛掉不符合条件的候选。剩下的商品交给 ProfileReranker，按用户画像做本地 Java 规则加权。这样调整品牌偏好或预算敏感度的权重时，可以直接查看计算规则。选出 Top 3 后，EmotionAgent 再解释为什么推荐它们。\n商家权限贯穿上述查询过程。Service 根据当前用户的可见商家范围，通过 SqlFilterBuilder 生成参数化的 merchant_id IN (?, ?, ?) 条件，注入商品、会话和订单查询。向量召回也在这个可见范围内执行，再从中选取 Top 20 候选。\n也就是说，先限定能查哪些商家的数据，再在这个范围里挑符合预算和属性要求的商品。不同 Agent 调用同一套 Service，使用相同的权限条件。\n六、怎么缩短语音回复的等待时间 文字回复慢一点，用户还能看着界面等；语音导购一直不出声，就很容易让人怀疑是不是没听见。所以 ASR 要边听边识别，回复也要尽早送入 TTS，同时还得等需求确认后再开始推荐。\n优化首字响应时，我首先调整了推荐路径中的串行调用。最初在商品筛选完成后，由 RecommendAgent 调用模型生成推荐理由，再交给 EmotionAgent 调整表达；后来将这两个生成步骤合并，让 EmotionAgent 直接根据商品名称、价格与属性组织推荐理由和口语回复。推荐链路继续负责提供结构化候选结果，回复生成减少了一次独立的模型调用。\n接下来，EmotionAgent 通过 AgentScope 的流式接口输出文本，按句号聚合切片后送入 CosyVoice。第一句完整文本就绪后即可开始合成，后续句子继续生成，不必等整段推荐结束。\n原始思路：推荐结果 → 推荐理由 LLM → 情感包装 LLM → 整段 TTS 调整思路：推荐结果 ────────────\u0026gt; 流式口语生成 │ 第一完整句 ▼ 按句发送到 TTS │ 边合成边播报 这里的首字响应时间，指从用户说完最后一个字，到 TTS 第一帧音频到达前端的时间。合并调用省掉了一次串行等待，按句送入 TTS 则让语音合成和后续文本生成同时进行。排查这类延迟时，把请求时间线画出来，看看每一步在等谁，比只盯着单个模型的速度更容易找到调整点。\n语音处理里还有一些小细节。ASR 的 partial_result 可能被后续识别修正，拿到中间结果就提交意图，可能判断错；按句切 TTS 时，“3.5 公里”里的小数点也可能被当成句子结束。切句时就得把这种数字格式考虑进去，否则一句话会在奇怪的地方断开。\n七、拆分之后，还要处理哪些工程问题 回复生成后，先经过 ComplianceChecker，再送入 TTS。它会处理绝对化用语和敏感词，例如把“最好”“保证”换成较温和的表达，必要时遮蔽敏感内容。语音一旦播出来就收不回了，所以这一步放在合成之前。\n另外，拆成四个 Worker 以后，要维护的东西也多了：每个角色都有 DTO、Prompt 和 Memory，修改输入输出时还要检查上下游调用。我选择 AgentScope，主要是看中 MsgHub、生命周期 Hook 和组合编排能力，用它来组织这些调用比较方便。框架本身的约定和版本兼容问题，也需要花时间处理。\n八、这次实践的收获与后续改进 回头看，这个项目里花心思的地方都很具体：用户换个说法，意图会不会跑偏；说“第二款”时，能不能找到上一轮的商品；推荐已经生成了，为什么还迟迟听不到声音。把这些问题逐个拆开，才慢慢有了现在的四个 Worker 和 Java 编排流程。\n接下来，我想继续看上下文窗口对意图判断的影响，也把 ASR、模型生成和 TTS 的耗时分开记录。这样再调整调用顺序或切句方式时，就能更清楚地知道改动有没有用。\n","permalink":"https://040012.xyz/posts/agentscope-java-voice-shopping/","summary":"一个语音导购项目的开发复盘：为什么拆成四个 Worker，哪些流程交给 Java 代码，以及上下文串场和语音等待该怎么处理。","title":"从单 Agent 到多 Agent：基于 AgentScope Java 的语音导购系统设计与实践"},{"content":"博客刚搭好，我又想到一个问题：现在这台服务器到期以后，如果不续费，换一台服务器，博客和域名怎么办？\n总不能每换一次服务器，就重新买一个域名，再让别人更新收藏夹。\n所以趁着现在的部署过程还记得，我把以后搬家的步骤也整理了一遍。这篇先作为迁移预案，等真正换服务器时，再补实际操作和遇到的问题。\n目前我的博客是 Hugo + PaperMod + Nginx：在 Mac 上写文章、生成网页，上传到服务器，用 https://040012.xyz/ 访问。下面按这套静态博客、域名直接连接服务器的部署方式来考虑。\n先把“换服务器”和“换域名”分开 我这里说的“把域名转移到新服务器”，实际操作是修改域名的 DNS 解析，让 040012.xyz 指向新的服务器 IP。\n域名本身可以继续用，注册商和 DNS 服务商也可以保持原样。\n需要迁移或重建的东西，主要有这些：\n内容 换服务器时怎么处理 域名 040012.xyz 保留，修改解析记录 Hugo 源文件 继续保存在 Mac，另外做独立备份 生成的网页 重新生成，上传到新服务器 Nginx 在新服务器安装并配置 HTTPS 证书 在新服务器重新申请，或安全迁移已有证书 续期任务 在新服务器重新检查和验证 因为域名没有变，Hugo 的 baseURL 仍然是：\nbaseURL = \u0026#39;https://040012.xyz/\u0026#39; 这里不用改成新服务器的 IP。以后上传命令里的服务器地址需要更新，但文章对外使用的网址可以保持不变。\n尽量在旧服务器到期前开始 虽然题目写的是“服务器到期后”，真要操作，我会尽量提前几天开始，让新旧服务器有一段重叠时间。\n如果旧服务器还在，新机器出问题时，至少还有地方可以回退。等新站稳定了，再处理旧机器，会从容很多。\n如果已经到期，我会先看云控制台里的实例和磁盘还在不在、有没有快照或恢复入口。具体能否恢复，要看当时的资源状态和平台规则，不能默认过期的数据一定还在。\n对现在这个博客来说，只要 Mac 上完整的 my-blog 文件夹还在，就可以重新生成网页，在新机器上重建网站。\n但这也让我意识到，源文件不能只放在一台电脑上。真正开始积累文章以后，需要备份的不只是 Markdown，还包括图片、hugo.toml、头像、主题和自己写的模板、样式。\n服务器上的网页是生成结果，域名只是访问入口，它们都不能代替完整的源文件备份。\n先准备新机器，暂时保留原来的解析 新服务器准备好后，我会先安装 Nginx，开放安全组入方向的 TCP 80 和 443 端口。\n如果仍然使用 Ubuntu，可以沿用这次搭建时的安装方式。下面的服务器命令按管理员权限的 root shell 写法展示；这是权限说明，不代表真实 SSH 登录账号。普通管理账号需按需加 sudo：\napt update \u0026amp;\u0026amp; apt install -y nginx certbot python3-certbot-nginx 然后配置博客站点。为了延续当前的目录结构，我会继续把网页放到 /var/www/html/，域名明确写成 040012.xyz：\nserver { listen 80; listen [::]:80; server_name 040012.xyz; root /var/www/html; index index.html; location / { try_files $uri $uri/ =404; } } 这段是新站点配置示例，需要保存到 Nginx 的站点配置目录并启用。新机器如果已经有其他网站，要先看现有配置，避免直接覆盖。\n配置启用后，先检查语法，通过后再重新加载：\nnginx -t \u0026amp;\u0026amp; systemctl reload nginx 此时还没有在新机器上配置 HTTPS，先确认 HTTP 能正确提供博客文件。\n接着在 Mac 上重新生成网页并上传。下面使用文档专用 TEST-NET-1 地址 192.0.2.10 和用户名占位符 your-ssh-user，均不是真实服务器信息。实际操作前必须替换 NEW_SERVER_IP 和 SERVER_USER，并确认账号有 /var/www/html/ 的写入权限；没有权限时先上传到可写暂存目录，再由管理员安装到网站目录。\nNEW_SERVER_IP=\u0026#39;192.0.2.10\u0026#39; SERVER_USER=\u0026#39;your-ssh-user\u0026#39; cd ~/my-blog hugo --minify \u0026amp;\u0026amp; scp -r public/. \u0026#34;$SERVER_USER@$NEW_SERVER_IP\u0026#34;:/var/www/html/ 我会先让新机器和旧机器提供同一版博客。迁移期间暂时少改文章，避免不同访客看到两套内容。\n不改 DNS，也能先测试新服务器 新服务器部署完成以后，不能只看到 Nginx 返回 200 就认为博客已经正常。默认欢迎页同样会返回 200，还要检查实际页面内容。\n这时域名仍然指向旧机器，可以在 Mac 上用 curl --resolve，只让这一次请求连接新机器：\ncurl --noproxy \u0026#39;*\u0026#39; \\ --resolve \u0026#34;040012.xyz:80:$NEW_SERVER_IP\u0026#34; \\ -I http://040012.xyz/ --resolve 给这次请求指定了域名和 IP 的对应关系，同时保留请求里的域名信息。它不会修改公共 DNS，也不需要先改电脑的 hosts 文件。\n再请求一篇已存在的文章，检查返回的标题和正文：\ncurl --noproxy \u0026#39;*\u0026#39; \\ --resolve \u0026#34;040012.xyz:80:$NEW_SERVER_IP\u0026#34; \\ http://040012.xyz/posts/hugo-blog-deployment/ 这一步主要确认新机器上的域名匹配、网页目录和文章路径没有配错。首页、文章、图片和搜索用到的 index.json 都应该检查。\nHugo 生成的链接仍然使用正式域名，所以这只能作为定向检查，不能把点开一个普通域名链接、又访问到旧服务器的结果，当成新服务器已经验证通过。\n再切换域名解析，给缓存留出时间 我会提前查看域名解析记录的 TTL，也就是解析结果可以被缓存多久。\n迁移前，可以把相关记录的 TTL 调低一些。例如平台支持时，设为 600 秒。需要注意的是，已经缓存了旧记录的 DNS 服务器，可能仍会按原来的 TTL 使用旧结果。\n因此，我会先降低 TTL，等原来的缓存有效期过去以后，再正式切换 IP。不会在改 IP 的同一分钟才降低 TTL，然后期待所有人马上访问新机器。\n切换时，要把根域名对应的 A 记录改成新服务器的 IPv4 地址。如果还使用 www，也要核对它的 A 记录或 CNAME 指向。\n还有一个容易漏掉的地方是 AAAA 记录。如果域名原来配置过 IPv6，就需要一起处理：新机器支持 IPv6，就更新地址；暂时不用，就移除对应的旧记录，避免部分访客仍然连到旧机器。\n改完以后，可以在 Mac 上查看当前网络查到的结果：\ndig +short A 040012.xyz dig +short AAAA 040012.xyz 查到新 IP，只能说明这次查询使用的解析链路已经更新，不代表全球缓存都已经刷新。不同网络在一段时间内访问到不同服务器，是需要提前考虑的情况。\n这也是我想让新旧机器短暂并行的原因。保留时间至少要覆盖旧 TTL，再留一些观察余量。如果预算允许，我会给自己预留一两天的重叠时间，而不是切完解析就立刻释放旧机器。\nHTTPS 要单独收尾 改了 DNS，证书文件不会自动出现在新服务器上。\n当前这套博客可以在新机器上重新申请证书。使用 Certbot 的 Nginx 插件时，常见的 HTTP-01 验证需要让 Let\u0026rsquo;s Encrypt 通过域名访问服务器的 80 端口。\n所以我会在外部解析已经能指向新机器、80 端口正常开放以后，再执行：\ncertbot --nginx -d 040012.xyz --redirect 它会验证域名、申请证书，并配置 HTTP 跳转到 HTTPS。\n这里有个顺序问题需要记住：如果先切 DNS，再在新机器申请证书，中间可能出现一小段 HTTPS 无法访问的时间。 我现在的网站已经会跳转到 HTTPS，访客和浏览器也可能直接使用 HTTPS，不能因为新机器的 HTTP 页面正常，就认为这一段没有影响。\n对个人博客来说，可以选择访问较少的时间完成切换。但如果到时希望尽量减少这段窗口，我会考虑提前通过 DNS-01 验证，在新服务器签好证书、配置并验证 HTTPS，再切换解析。\nDNS-01 是通过域名的 TXT 记录验证控制权，不要求网站已经解析到新服务器。不过，如果靠手工添加 TXT 记录完成验证，后续续期也不会因此自动解决。要长期自动续期，还需要配置受支持的 DNS API 插件或验证钩子。\n这两种顺序各有操作成本。到真正迁移时，我会根据是否能接受短暂中断来选，不把“换个 A 记录”直接理解成无缝迁移。\n证书配置完成后，还要做续期演练，并检查定时任务：\ncertbot renew --dry-run systemctl status certbot.timer --no-pager 旧服务器上的定时任务正常，不能证明新服务器上的续期也正常。这一步需要在新机器重新检查。\n最后验证，确认后再退掉旧机器 解析切换以后，我会先定向检查新服务器的 HTTPS：\ncurl --noproxy \u0026#39;*\u0026#39; \\ --resolve \u0026#34;040012.xyz:443:$NEW_SERVER_IP\u0026#34; \\ -I https://040012.xyz/ 这里不加 -k。我需要确认的是，连接到新 IP 时，证书校验也能正常通过。\n然后再用普通访问方式检查域名：\ncurl --noproxy \u0026#39;*\u0026#39; -I https://040012.xyz/ curl --noproxy \u0026#39;*\u0026#39; -I http://040012.xyz/ 预期是 HTTPS 正常返回，HTTP 跳转到 HTTPS。浏览器里再检查首页、关于页、文章、头像、项目链接和搜索；用手机切到移动网络再访问一次，也能多验证一条实际访问链路。\n观察期间，我会看一下新旧机器的访问日志。如果出问题，旧机器还在时，可以把解析改回去，但回退同样会受到 DNS 缓存影响，不是按一下保存就所有人立刻回到旧站。\n确认新站稳定以后，再调整 TTL、保留必要备份，最后处理旧服务器。旧机器到期时间、新机器费用和域名续费是几件独立的事，需要分别记住。\n还有一项很实际的收尾：把以后发布博客用的上传命令改成新 IP。否则网站已经搬走了，我还在往旧机器上传，写完文章线上自然不会更新。\nhugo --minify \u0026amp;\u0026amp; scp -r public/. \u0026#34;$SERVER_USER@$NEW_SERVER_IP\u0026#34;:/var/www/html/ 目前这个预案只覆盖静态博客。如果以后还在服务器上部署了 Java 项目、PostgreSQL、Redis，那些服务和数据需要另外安排迁移，不能靠上传 public 一起搬过去。\n整理下来，Hugo 在这件事上的好处也更明显了：完整的源文件在，网页就能重新生成；域名和文章路径保持一致，换服务器时读者通常不需要记一个新地址。\n等真正搬家时，还是按这个顺序来：先把新站准备好，再处理解析和证书，最后验证并收尾。到期之前留一点时间，会比到期以后再想办法轻松得多。\n参考资料：\n阿里云 DNS：TTL 的含义与配置 Let\u0026rsquo;s Encrypt：域名验证方式 Certbot：Nginx 配置说明 ","permalink":"https://040012.xyz/posts/migrate-hugo-blog-to-new-server/","summary":"\u003cp\u003e博客刚搭好，我又想到一个问题：现在这台服务器到期以后，如果不续费，换一台服务器，博客和域名怎么办？\u003c/p\u003e\n\u003cp\u003e总不能每换一次服务器，就重新买一个域名，再让别人更新收藏夹。\u003c/p\u003e\n\u003cp\u003e所以趁着现在的部署过程还记得，我把以后搬家的步骤也整理了一遍。这篇先作为迁移预案，等真正换服务器时，再补实际操作和遇到的问题。\u003c/p\u003e\n\u003cp\u003e目前我的博客是 \u003cstrong\u003eHugo + PaperMod + Nginx\u003c/strong\u003e：在 Mac 上写文章、生成网页，上传到服务器，用 \u003ccode\u003ehttps://040012.xyz/\u003c/code\u003e 访问。下面按这套静态博客、域名直接连接服务器的部署方式来考虑。\u003c/p\u003e\n\u003ch2 id=\"先把换服务器和换域名分开\"\u003e先把“换服务器”和“换域名”分开\u003c/h2\u003e\n\u003cp\u003e我这里说的“把域名转移到新服务器”，实际操作是修改域名的 DNS 解析，让 \u003ccode\u003e040012.xyz\u003c/code\u003e 指向新的服务器 IP。\u003c/p\u003e\n\u003cp\u003e域名本身可以继续用，注册商和 DNS 服务商也可以保持原样。\u003c/p\u003e\n\u003cp\u003e需要迁移或重建的东西，主要有这些：\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003e内容\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e换服务器时怎么处理\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e域名 \u003ccode\u003e040012.xyz\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e保留，修改解析记录\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eHugo 源文件\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e继续保存在 Mac，另外做独立备份\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e生成的网页\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e重新生成，上传到新服务器\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eNginx\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e在新服务器安装并配置\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eHTTPS 证书\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e在新服务器重新申请，或安全迁移已有证书\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e续期任务\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e在新服务器重新检查和验证\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e因为域名没有变，Hugo 的 \u003ccode\u003ebaseURL\u003c/code\u003e 仍然是：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-toml\" data-lang=\"toml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003ebaseURL\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;https://040012.xyz/\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这里不用改成新服务器的 IP。以后上传命令里的服务器地址需要更新，但文章对外使用的网址可以保持不变。\u003c/p\u003e\n\u003ch2 id=\"尽量在旧服务器到期前开始\"\u003e尽量在旧服务器到期前开始\u003c/h2\u003e\n\u003cp\u003e虽然题目写的是“服务器到期后”，真要操作，我会尽量提前几天开始，让新旧服务器有一段重叠时间。\u003c/p\u003e\n\u003cp\u003e如果旧服务器还在，新机器出问题时，至少还有地方可以回退。等新站稳定了，再处理旧机器，会从容很多。\u003c/p\u003e\n\u003cp\u003e如果已经到期，我会先看云控制台里的实例和磁盘还在不在、有没有快照或恢复入口。具体能否恢复，要看当时的资源状态和平台规则，不能默认过期的数据一定还在。\u003c/p\u003e\n\u003cp\u003e对现在这个博客来说，只要 Mac 上完整的 \u003ccode\u003emy-blog\u003c/code\u003e 文件夹还在，就可以重新生成网页，在新机器上重建网站。\u003c/p\u003e\n\u003cp\u003e但这也让我意识到，源文件不能只放在一台电脑上。真正开始积累文章以后，需要备份的不只是 Markdown，还包括图片、\u003ccode\u003ehugo.toml\u003c/code\u003e、头像、主题和自己写的模板、样式。\u003c/p\u003e\n\u003cp\u003e服务器上的网页是生成结果，域名只是访问入口，它们都不能代替完整的源文件备份。\u003c/p\u003e\n\u003ch2 id=\"先准备新机器暂时保留原来的解析\"\u003e先准备新机器，暂时保留原来的解析\u003c/h2\u003e\n\u003cp\u003e新服务器准备好后，我会先安装 Nginx，开放安全组入方向的 TCP 80 和 443 端口。\u003c/p\u003e","title":"服务器到期后，我准备这样把博客搬到新服务器"},{"content":"这两天把自己的博客搭起来了，地址是 040012.xyz。\n一开始的想法很简单：找个自己的地方，放学习笔记、项目复盘，还有一些平时折腾东西留下的记录。文章可以慢慢写，先把地方准备好。\n最后选的是 Hugo + PaperMod + Nginx，在 Mac 上写文章、生成网页，再上传到 Ubuntu 服务器。域名和 HTTPS 也一起配好了。\n过程不算复杂，但从“服务器本机能访问”到“别人打开域名也能访问”，中间还是踩了几个坑。趁着还记得，记录一下。\n为什么最后选了 Hugo 我最先考虑的其实是服务器资源。\n选型时，我是按 2 核、2 GB 内存的小服务器来考虑的，还想在上面部署自己的 Java 语音购物项目。如果博客再常驻一个后端、再加数据库，后面几个服务一起跑，内存就会比较紧。\n这里也只是部署前的判断。我还没有做过博客和 Java 项目同时运行的负载测试，不能因为博客现在能打开，就说整台服务器能稳定承载多少人。\n当时主要看了几种方案：\n方案 我当时的考虑 WordPress 有网页后台，扩展也多，但我暂时只想写文章，不需要那么多功能 Halo 后台管理方便，但还要再运行一个 Java 服务，对当前服务器来说需要考虑内存余量 Typecho 如果一定要浏览器后台写文章，我会优先考虑它，PHP 加 SQLite 的组合也比较轻 Hexo 同样可以生成静态网页，主题选择丰富，也是合适的方案 Hugo 静态部署，PaperMod 的样子又刚好符合我想要的简洁风格，最后就选了它 我喜欢 Hugo 的地方是，生成完之后就是一堆 HTML、CSS 和 JavaScript 文件。服务器用 Nginx 把这些文件提供出去就行，不需要一直运行 Hugo，也不用为了博客单独维护数据库。\n当然，这也有取舍。Hugo 没有自带一个让我登录进去发文章、拖拽装修页面的后台。文章要在电脑上写，发布时再生成和上传。常用设置改配置文件，更深的页面调整需要接触 CSS 和模板。\n目前我主要想记录技术内容，这个写作方式可以接受。文章也能用 Typora 这类编辑器来写，不需要一直对着纯文本。\n这套博客是怎么运行的 我一开始还把“服务器上部署 Hugo”和“服务器上一直运行 Hugo”混在了一起。实际用起来，分工很清楚：\nMac 上写 Markdown ↓ Hugo 生成 public 文件夹 ↓ 上传到服务器 /var/www/html/ ↓ Nginx 提供网页，浏览器通过域名访问 本地的 hugo server 只是方便预览。正式网站已经上传到服务器，所以本地终端关掉、电脑关机，都不会让线上博客停止运行。\n这点对我来说挺省心。以后博客的运行环境主要就是 Nginx，写作环境则留在自己的电脑上。\n先在 Mac 上把页面跑起来 我用 Homebrew 安装了 Hugo：\nbrew install hugo hugo version 然后创建博客，下载 PaperMod 主题。下面这组命令是首次创建时用的，已经建好的目录不用重复执行：\nhugo new site ~/my-blog cd ~/my-blog git clone --depth 1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod 我就遇到过“目录已经存在且不为空”的提示。检查后发现博客和主题都已经下载好了，直接继续配置就行，没必要强行重新创建。\n博客的设置放在 hugo.toml 里。目前主要是这些：\nbaseURL = \u0026#39;https://040012.xyz/\u0026#39; languageCode = \u0026#39;zh-cn\u0026#39; defaultContentLanguage = \u0026#39;zh\u0026#39; title = \u0026#39;ZYJ 的博客\u0026#39; theme = \u0026#39;PaperMod\u0026#39; [params] defaultTheme = \u0026#39;auto\u0026#39; ShowReadingTime = true ShowCodeCopyButtons = true [params.homeInfoParams] Title = \u0026#39;ZYJ 的博客\u0026#39; Content = \u0026#39;记录技术、学习与生活。\u0026#39; 这是现在启用 HTTPS 之后的配置。最开始还没有证书时，baseURL 用的是 HTTP。\n当前 Hugo 对 languageCode 会给出弃用提示，后面整理配置时需要按新版文档迁移到 locale。这次构建没有因此失败，所以我先把网站部署流程走通了。\n本地预览用：\ncd ~/my-blog hugo server -D --disableFastRender 浏览器打开 http://localhost:1313/ 就能看效果。-D 表示预览时也显示草稿。\n第一篇测试文章加进去后，我碰到过首页没有显示文章的情况。排查发现，文章文件存在，直接访问文章页也正常，原配置完整构建后同样能生成首页文章列表。问题出在当时运行的预览没有更新完整。\n最后重启预览，并加上 --disableFastRender。这次排查也提醒我：页面没显示，不一定就是文章格式写错了，先确认文件、生成结果和预览状态，别急着到处改配置。\n服务器装好了 Nginx，为什么外面还是进不来 服务器用的是 Ubuntu 24.04 LTS。我先检查了正在监听的端口，确认 80、443 没有被其他服务占用，再安装 Nginx：\napt update \u0026amp;\u0026amp; apt install -y nginx 这里的服务器命令按管理员权限的 root shell 写法展示，所以没有加 sudo；这是权限说明，不代表公开真实 SSH 登录账号。使用普通管理账号时，应按需加 sudo。\n安装完，在服务器里测试：\ncurl -I http://127.0.0.1 返回了 200 OK，但电脑浏览器访问却没有成功，起初还显示了 502。\n我没有直接把 502 当成 Nginx 配置错误，而是先看访问日志：\ntail -n 20 /var/log/nginx/access.log 里面只有服务器本机那次请求，看不到刚才浏览器的访问。接着在 Mac 上绕过代理，直接测试公网 IP 的 80 端口，结果是连接超时。\n又检查了两项：\nufw status verbose ss -lntp \u0026#39;( sport = :80 )\u0026#39; UFW 没有启用，Nginx 也确实监听在 0.0.0.0:80 和 [::]:80。\n最后在阿里云控制台看安全组，才发现入方向规则里根本没有开放 80 端口。加上允许 TCP 80 的规则后，Mac 再请求就收到了 200 OK，浏览器也能打开 Nginx 欢迎页。\n这是这次最有用的一次排查：服务器本机访问成功，只能说明本机这一段正常，不能直接说明公网链路也通了。\n以后碰到类似问题，我会分别看服务有没有运行、监听地址对不对、系统防火墙和云安全组有没有放行，而不是只盯着网页报错。\n从欢迎页换成自己的博客 域名指向服务器后，打开 040012.xyz，最初看到的还是 Welcome to nginx!。\n这说明域名访问已经到了 Nginx，但博客文件还没有上传。\n我先在 Mac 上设置好 baseURL，再执行构建和上传。下面保留当时的手动发布流程，其中 Nginx 的默认网站目录是已经检查过的 /var/www/html/。真实服务器地址和登录账号已替换为文档示例：192.0.2.10 属于专供文档使用的 TEST-NET-1 地址段，your-ssh-user 是用户名占位符，不能直接用于实际部署。\n实际操作时需替换两个变量，并确认该账号有目标目录的写入权限；如果没有，应上传到该账号可写的暂存目录，再由管理员安装到网站目录。这里没有假定普通用户可以直接写入 /var/www/html/。\ncd ~/my-blog SERVER_IP=\u0026#39;192.0.2.10\u0026#39; SERVER_USER=\u0026#39;your-ssh-user\u0026#39; hugo --minify \u0026amp;\u0026amp; scp -r public/. \u0026#34;$SERVER_USER@$SERVER_IP\u0026#34;:/var/www/html/ hugo --minify 生成用于发布的网页，scp 再把 public 里的内容传到服务器。中间用 \u0026amp;\u0026amp;，是为了构建成功后再上传。\n上传的是生成后的网页，不需要把整套 Hugo 开发环境搬到服务器。\n现在这个方式适合我手动更新博客，但它不会删除服务器上的旧文件。以后如果删除文章或者修改文章文件名，需要同步处理服务器上的旧页面，否则旧网址可能仍然可以访问。后面文章多了，再考虑专用部署目录和更完整的同步流程。\n网站已经能访问了，为什么还要配 HTTPS 我一开始也问过这个问题：输入域名已经能看到文章，为什么还要继续折腾？\n后来理解了，能打开和连接是否加密是两回事。HTTP 没有为传输内容提供加密和完整性保护，HTTPS 则能让浏览器验证域名证书，并保护浏览器与服务器之间传输的内容。\n我的博客目前没有登录和支付，HTTPS 的紧迫程度没那么高。但既然准备长期使用、把网址分享出去，还是配好比较合适。证书可以免费申请，当前这套环境也不需要因此升级服务器。\n实际操作先是在阿里云安全组放行 TCP 443，同时保留 80。443 用于 HTTPS 访问；这次使用的 HTTP-01 域名验证仍然需要 80，原来的 HTTP 入口也可以负责跳转。\n然后明确 Nginx 的域名配置：\nserver_name 040012.xyz; 原来的 server_name _; 是占位值。之前域名能打开，是因为这个站点被设置成了默认站点，不代表已经明确配置了这个域名。\n修改前备份配置，改完检查语法，通过后再重新加载：\nnginx -t \u0026amp;\u0026amp; systemctl reload nginx 证书工具使用 Certbot 和它的 Nginx 插件：\napt update \u0026amp;\u0026amp; apt install -y certbot python3-certbot-nginx certbot --nginx -d 040012.xyz --redirect Certbot 验证域名后，申请了 Let\u0026rsquo;s Encrypt 证书，并自动修改 Nginx 配置。--redirect 让 HTTP 请求自动跳转到 HTTPS。\n签发完成后，实际验证到的结果是：\nhttps://040012.xyz/ 返回 200 OK，证书校验正常。 http://040012.xyz/ 返回 301，跳转到 HTTPS。 我还做了一次续期演练，并检查定时任务：\ncertbot renew --dry-run systemctl status certbot.timer --no-pager 模拟续期成功，定时任务也是 enabled、active (waiting)。它会定期检查是否需要续期，并不是每次运行都重新申请证书。\n最后把 Mac 上 hugo.toml 的 baseURL 改为 https://040012.xyz/，重新生成、上传，检查文章链接也保持 HTTPS，这一步才算收尾。\n以后怎么写，以及还要维护什么 写新文章时，在 Mac 上执行：\ncd ~/my-blog hugo new content posts/文章文件名.md 正文写在 content/posts/ 下面。准备发布时，把文章顶部的 draft 改为 false，日期也不要设到未来。图片可以放到 static/images/，正文里用 /images/图片名 引用。\n本地预览满意后，再运行构建和上传命令。发布之后，用线上地址检查一遍文章和图片。\n运维方面，目前主要关心域名续费、系统更新、磁盘空间，以及证书续期是否正常。自动续期已经配置好，但以后改了域名解析、端口规则或 Nginx 配置，还是要重新检查，不能把“自动”理解成永远不用管。\n源文件备份我还没有正式配置。前面搭建时只有测试文章，所以暂时没急着做。开始积累正式内容后，准备把文章、图片、配置和自定义主题文件一起做独立备份，至少不要只放在同一台电脑上。\n服务器上的 public 网页可以重新生成，但它不能代替原始 Markdown、配置和主题修改记录。\n折腾完这一轮，我觉得这套方案目前挺适合我：页面简单，写作方式也直接，服务器端需要维护的东西比较少。代价是没有现成的网页管理后台，主题定制也需要自己接触配置和样式。\n先把它用起来。接下来比继续换主题更重要的，应该是往里面认真写几篇文章。\n","permalink":"https://040012.xyz/posts/hugo-blog-deployment/","summary":"\u003cp\u003e这两天把自己的博客搭起来了，地址是 \u003ca href=\"https://040012.xyz/\"\u003e040012.xyz\u003c/a\u003e。\u003c/p\u003e\n\u003cp\u003e一开始的想法很简单：找个自己的地方，放学习笔记、项目复盘，还有一些平时折腾东西留下的记录。文章可以慢慢写，先把地方准备好。\u003c/p\u003e\n\u003cp\u003e最后选的是 \u003cstrong\u003eHugo + PaperMod + Nginx\u003c/strong\u003e，在 Mac 上写文章、生成网页，再上传到 Ubuntu 服务器。域名和 HTTPS 也一起配好了。\u003c/p\u003e\n\u003cp\u003e过程不算复杂，但从“服务器本机能访问”到“别人打开域名也能访问”，中间还是踩了几个坑。趁着还记得，记录一下。\u003c/p\u003e\n\u003ch2 id=\"为什么最后选了-hugo\"\u003e为什么最后选了 Hugo\u003c/h2\u003e\n\u003cp\u003e我最先考虑的其实是服务器资源。\u003c/p\u003e\n\u003cp\u003e选型时，我是按 2 核、2 GB 内存的小服务器来考虑的，还想在上面部署自己的 Java 语音购物项目。如果博客再常驻一个后端、再加数据库，后面几个服务一起跑，内存就会比较紧。\u003c/p\u003e\n\u003cp\u003e这里也只是部署前的判断。我还没有做过博客和 Java 项目同时运行的负载测试，不能因为博客现在能打开，就说整台服务器能稳定承载多少人。\u003c/p\u003e\n\u003cp\u003e当时主要看了几种方案：\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003e方案\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e我当时的考虑\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eWordPress\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e有网页后台，扩展也多，但我暂时只想写文章，不需要那么多功能\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eHalo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e后台管理方便，但还要再运行一个 Java 服务，对当前服务器来说需要考虑内存余量\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eTypecho\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e如果一定要浏览器后台写文章，我会优先考虑它，PHP 加 SQLite 的组合也比较轻\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eHexo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e同样可以生成静态网页，主题选择丰富，也是合适的方案\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eHugo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e静态部署，PaperMod 的样子又刚好符合我想要的简洁风格，最后就选了它\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e我喜欢 Hugo 的地方是，生成完之后就是一堆 HTML、CSS 和 JavaScript 文件。服务器用 Nginx 把这些文件提供出去就行，不需要一直运行 Hugo，也不用为了博客单独维护数据库。\u003c/p\u003e\n\u003cp\u003e当然，这也有取舍。Hugo 没有自带一个让我登录进去发文章、拖拽装修页面的后台。文章要在电脑上写，发布时再生成和上传。常用设置改配置文件，更深的页面调整需要接触 CSS 和模板。\u003c/p\u003e\n\u003cp\u003e目前我主要想记录技术内容，这个写作方式可以接受。文章也能用 Typora 这类编辑器来写，不需要一直对着纯文本。\u003c/p\u003e\n\u003ch2 id=\"这套博客是怎么运行的\"\u003e这套博客是怎么运行的\u003c/h2\u003e\n\u003cp\u003e我一开始还把“服务器上部署 Hugo”和“服务器上一直运行 Hugo”混在了一起。实际用起来，分工很清楚：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMac 上写 Markdown\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ↓\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eHugo 生成 public 文件夹\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ↓\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e上传到服务器 /var/www/html/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ↓\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eNginx 提供网页，浏览器通过域名访问\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e本地的 \u003ccode\u003ehugo server\u003c/code\u003e 只是方便预览。正式网站已经上传到服务器，所以本地终端关掉、电脑关机，都不会让线上博客停止运行。\u003c/p\u003e","title":"用 Hugo 搭了一个自己的博客，顺手把部署过程记下来"}]