← 返回蜂巢洞察

如何构建一个能够确保生产环境安全运行的代理循环系统:从退出条件到审计追踪机制

在2025年7月,一个基于Claude Code的递归循环在五小时内消耗了16, … Read More

Socrates

在2025年7月,一个基于Claude Code的递归循环在五小时内消耗了16,000美元到50,000美元不等的费用。并没有出现系统崩溃或错误,只是这些代理程序一直按照指令执行操作,因为没有人告诉它们何时应该停止。

四个月后,一个由四个代理程序组成的LangChain循环运行了11天,最终花费了47,000美元。直到账单寄到手中,才有人注意到这个问题。在测试阶段,这个系统运行正常,所有代理程序也都严格按照指令行事——同样的情况再次发生了。

本教程就是为了探讨这种“缺失的指令”问题而设计的。

你将构建五个简单的Python工具,这些工具能够在大多数代理循环出现故障之前及时发现并阻止问题的发生:

  • 一个规范编写工具,它要求你在循环开始之前就明确定义“完成条件”。

  • 一个断路器机制,当循环超出预设的限制时,它会立即终止该循环。

  • 一个记录系统,它会将循环中的每一步操作都记录在只允许追加数据的SQLite审计日志中。

  • 一个将这三个工具结合在一起的代理循环框架

  • 一个审核机制,它在任何下游系统接收数据之前,都会要求人工进行确认。

完成这些开发后,你将会得到一个可以直接应用于任何代理项目的功能完备的代码库。完整代码请访问github.com/dannwaneri/production-safe-agent-loop

目录

  1. 为什么这种问题会不断发生

  2. 先决条件

  3. 阶段1:在开发之前明确完成条件

  4. 阶段2:在运行时强制执行完成条件

  5. 阶段3:记录所有的操作过程

  6. 阶段4:遵循规则运行的循环机制

  7. 阶段5:审核机制

  8. 阶段6:实际应用示例——SEO审计代理

  9. 可插拔的LLM客户端

  10. 运行测试

  11. 你所完成的工作

  12. 下一步计划

为什么这种问题会不断发生

导致企业遇到麻烦的数学原理其实很简单:一个聊天机器人的每次交互成本大约为0.04美元,而一个由多个代理程序组成的复杂工作流程的成本则为1.20美元。这意味着两者之间的成本差距高达30倍——而在处理复杂任务时,这一差距甚至可能达到70倍。

问题并不在于代理程序本身价格昂贵,而在于大多数团队在预算规划时只考虑了聊天机器人的成本,却忽略了部署代理程序架构所需的费用。Gartner的研究发现,试点阶段的聊天机器人与正式生产环境中的代理程序在工作流程上的成本差距介于5倍到30倍之间。FinOps Foundation在2026年发布的报告中也指出,73%的企业表示,人工智能相关成本的实际情况超出了最初的预测。

一旦了解了这个机制,就会发现它其实非常简单。当一个代理在执行任务时失败并尝试重新运行时,它并不会从零开始执行,而是会重新读取所有的上下文信息——包括之前所有失败的尝试记录——然后再进行尝试。第一次迭代需要消耗100个令牌,第二次迭代需要200个令牌,而第十次迭代则可能需要数千个令牌。也就是说,每次失败都会让使用者付出相应的代价,而这些代价是以毫秒为单位计算的。

# 这整个流程可以用三行代码来表示:
while True:
result = agent.run(task)
# 什么时候才算完成呢?...

而恰恰就是这个“什么时候才算完成”这个问题,导致了大量的资源被消耗掉。

还有另一个让情况变得更糟的因素是:这些代理在遇到问题时并不会出现明显的错误提示。传统的代码在遇到未知状态时会崩溃,而大型语言模型在面对模糊性或不确定性时,会尝试继续执行并提供帮助。它们会重新尝试执行任务,会调整调用命令的格式,甚至会启动额外的验证程序。然而,由于没有人明确界定“正确”的标准是什么,这些尝试最终往往还是会导致问题再次发生。在各种监控仪表板上,这些操作看起来都很正常——活动量、调用次数、完成率等等——但实际上却在悄悄地消耗着你的预算。

根据Gartner的预测,到2027年,有40%的基于代理的技术项目会因为经济原因而被终止。而其中大部分失败其实都是可以避免的。关键不在于使用更好的模型,而在于设置合理的退出条件。

先决条件

  • Python 3.10或更高版本

  • Anthropic的API密钥(或其他服务提供商提供的密钥——具体细节稍后介绍)

  • 对Python类和SQLite有一定的了解

git clone https://github.com/dannwaneri/production-safe-agent-loop
cd production-safe-agent-loop
pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-...

第1阶段:在开始开发之前先明确“完成的标准”

在代理程序的开发过程中,最代价高昂的错误并不是选择了错误的模型,也不是没有设置重试次数限制。真正的错误在于在还没有弄清楚“完成的标准”之前就开始了开发工作。

大多数团队都无法回答这个问题。这并不是因为他们粗心大意,而是因为在他们打开终端开始编写代码之前,没有任何东西迫使他们去思考这个问题。而“规范编写者”恰恰就是那个能够起到这种强制作用的角色。

# spec_writer.py
from spec_writer import SpecWriter

spec = SpecWriter(db_path="spec.db").run()

当你调用.run()这个方法时,它不会立即返回结果,直到你回答了以下三个问题为止:

  1. 这个代理程序的作用是什么?

  2. 这个代理程序不能做什么?

  3. 用一句话来描述“完成的标准”是什么?

  4. 其中第三个问题才是真正关键的问题,也是最难回答的。“这个代理程序会审核网站内容”这样的答案是不够的。而“这个代理程序会爬取目标网址,提取所有的</code>和<code><meta description></code>标签,检查是否存在缺失或过长的标签,然后停止执行”这样的答案才符合要求。只有明确了这样的标准,才能让系统知道应该如何进行验证。</p> <p>编写好的规范会被保存到SQLite数据库中,并返回一个包含<code>session_id</code>字段的<code>SpecResult</code>数据对象。这个ID就是连接你的规范文件、数据库记录以及程序执行结果的纽带,它能够确保所有的操作都能被从头到尾追踪起来。</p> <pre><code class="language-python">@dataclass(frozen=True) class SpecResult: what_it_does: str what_it_does_not: str done_looks_like: str session_id: str </code></pre> <p><code>frozen=True</code>这一设置非常重要。规范文件是一种具有约束力的文件,而不是草稿。一旦编写完成,后续的所有操作都必须严格遵循这些规范,不允许在运行过程中对其进行修改。</p> <p>在测试过程中,<code>SpecWriter</code>会接受可插入的<code>input_fn</code>和<code>output_fn》函数作为输入参数,因此无需对标准输入进行任何修改。具体的使用示例可以参考文件<code>tests/test_spec_writer.py</code>——该测试用例中使用了<code>scripted_input</code>辅助工具,该工具可以从生成器中获取答案,并通过pytest的<code>tmp_path</code>机制将结果写入每个测试对应的SQLite文件中。需要注意的是,使用SQLite的<code>:memory:</code>数据库模式并不安全,因为<code>SpecWriter</code>会为每种测试方法创建一个新的连接,而每个<code>:memory:</code>连接都代表一个独立的数据库环境。</p> <h2 id="heading-phase-2-enforce-done-at-runtime">阶段2:在运行时强制执行操作结束</h2> <p>在代码的上游明确规定退出条件是一种必要的纪律要求,而“断路器机制”则是确保这些规则得到严格执行的有效手段。</p> <pre><code class="language-python"># circuit_breaker.py from circuit_breaker import CircuitBreaker, CircuitBreakerError breaker = CircuitBreaker(turn_limit=5, token_limit=15000) breaker.check(turn_count, accumulated_tokens) # 当超过限制时会触发异常</code></pre> <p>这里设置了两个严格的上限限制:<code>turn_limit</code>规定了循环最多可以调用LLM多少次,而<code>token_limit</code>则限制了所有调用过程中消耗的总令牌数。一旦任何一个上限被突破,就会立即引发<code>CircuitBreakerError</code>异常。</p> <p>这些限制是非常严格的:即使<code>turn_count</code>恰好等于<code>turn_limit</code>,也会触发异常;不允许有任何缓冲期或警告提示,系统会立即停止运行,迫使人类审核员介入处理。</p> <pre><code class="language-python">from dataclasses import dataclass @dataclass class CircuitBreakerError(Exception): reason: str # "turn_ceiling" 或 "token_ceiling" turn_count: int accumulated_tokens: int def __post_init__(self) -> None: super().__init__( f"断路器触发异常:{self.reason} " f"(当前调用次数:{self.turn_count}, 总消耗令牌数:{self.accumulated_tokens}") ) class CircuitBreaker: def __init__(self, turn_limit: int = 5, token_limit: int = 15000) -> None: self(turn_limit = turn_limit self.token_limit = token_limit def check(self, turn_count: int, accumulated_tokens: int) -> None: if turn_count > self.turn_limit: self._trip("turn_ceiling", turn_count, accumulated_tokens) if accumulated_tokens > self.token_limit: self._trip("token_ceiling", turn_count, accumulated_tokens) def _trip(self, reason: str, turn_count: int, accumulated_tokens: int) -> None: print( "\n=== 断路器检查点 ===\n" f"异常原因 : {reason}\n" f"当前调用次数 : {turn_count} / 最大限制 {self.turn_limit}\n" f>总消耗令牌数 : {accumulated_tokens} / 最大限制 {self.token_limit}\n" "处理措施 : 停止循环,由人类审核员介入\n" "==================================" ) raise CircuitBreakerError( reason=reason, turn_count=turn_count, accumulated_tokens=accumulated_tokens, ) </code></pre> <p><code>CircuitBreakerError</code>是一种异常,而不是返回码。这种设计是有意为之的:返回码可以被忽略,但未捕获的异常却不能被忽视。因此,隐藏异常的状态是不可能的。<code>_trip()</code>函数会在异常被抛出<em>之前</em>将那些便于人类阅读的检查点信息输出到标准输出中,所以即使调用者试图掩盖这个异常,操作员仍然能够了解到系统的状态。</p> <p>一个非常重要的规则是:在每次调用大型语言模型之前,必须先调用<code>.check()</code>函数,而不是之后。如果在执行完操作后才进行检查,那就意味着在发现超出限制之前,你已经消耗了所有的令牌。</p> <pre><code class="language-python"># 错误的做法——在执行后进行检查 result = client.messages.create(...) breaker.check(turn_count, accumulated_tokens) # 这已经太晚了 # 正确的做法——在执行前进行检查 breaker.check(turn_count, accumulated_tokens) # 这会在任何操作发生之前就触发检查 result = client.messages.create(...) </code></pre> <p>默认的设置(5次循环,15,000个令牌)适用于简单的教程演示。但在实际生产环境中,你的资源预算可能是不同的,因此需要在创建对象时根据实际情况进行调整:</p> <pre><code class="language-python"># 生产环境示例——减少令牌数量,增加循环次数 breaker = CircuitBreaker(turn_limit=10, token_limit=50000) </code></pre> <h2 id="heading-phase-3-record-everything">第三阶段:记录一切</h2> <p>断路器的作用是保护你的“资金账户”,而账本的作用则是帮助你清晰地了解究竟发生了什么。</p> <p>大多数团队会进行日志记录以便于调试——他们希望在出现问题后能够查明原因。但账本的目的有所不同,它的作用在于实现对整个流程的监管。账本中的每一条记录都能证明该循环是否在规定的范围内进行,以及具体是在什么时候超出了范围。</p> <pre><code class="language-python"># ledger.py from ledger import Ledger ledger = Ledger(db_path="ledger.db") ledger.write( session_id=spec.session_id, turn_count=1, state_origin="llm", input_str=task, token_delta=523, execution_time_ms=1240, pass_fail=True, ) </code></pre> <p>每次循环都会在账本中记录一条数据。这个账本是只支持追加操作,不允许进行更新或删除操作。这种不可变性的设计正是其核心所在:如果一个账本可以被随意修改,那么它就不再是一个真正的账本了,而更像是一本笔记。</p> <p>账本的数据库结构如下:</p> <pre><code class="language-sql">CREATE TABLE IF NOT EXISTS ledger ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, turn_count INTEGER NOT NULL, state_origin TEXT NOT NULL, input_hash TEXT NOT NULL, token_delta INTEGER NOT NULL, execution_time_ms INTEGER NOT NULL, pass_fail INTEGER NOT NULL, -- 1表示通过,0表示失败 breach_reason TEXT, -- 除非断路器被触发,否则这个字段为NULL created_at TEXT NOT NULL -- 使用ISO 8601格式,时间戳以UTC为准 ); CREATE INDEX IF NOT EXISTS idx_ledger_session ON ledger(session_id); </code></pre> <p>通过创建索引,即使账本中的数据量逐渐增加,<code>get_session(session_id)</code>这种主要的查询操作也能保持恒定的查询速度。</p> <ol> <li> <p><code>input_hash</code>存储的是输入字符串的SHA-256哈希值,而不是原始输入字符串本身。这样做有两个好处:首先,可以检测出不同运行环境中相同的输入数据;其次,个人身份信息永远不会被记录到审计日志中。</p> </li> <li> <p><code>pass_fail</code>被定义为整数类型,而不是布尔类型。这是因为SQLite数据库中没有布尔类型,使用<code>1</code>和<code>0</code>来表示通过或失败是符合Python的编程习惯的,同时也能确保数据库中的数据类型与SQL标准保持一致。</p> </li> <li> <p><code>created_at</code>字段存储的是当前时间戳,但这个时间戳是按照UTC时区进行计算的。在Python 3.12之后,<code>datetime.utcnow()</code>这个函数已经被弃用,因此使用<code>datetime.now(timezone.utc).isoformat()</code>才能确保时间戳的正确性。</p> </li> </ol> <p>按会话检索:</p> <pre><code class="language-python">rows = ledger.get_session(spec.session_id) for row in rows: print(f"当前轮次:{row.turn_count}:{'通过' if row.pass_fail else '失败'} " f"| {row.token_delta} 个代币 | {rowexecution_time_ms} 毫秒") </code></pre> <h2 id="heading-phase-4-the-loop-that-respects-its-boundaries">阶段 4:遵循自身界限的循环</h2> <p>代理循环将这三个基本组件连接在一起。它是唯一会调用大型语言模型的组件,其他所有操作都是在本地进行的。</p> <pre><code class="language-python"># agent_loop.py from agent_loop import AgentLoop loop = AgentLoop(spec, breaker, ledger, client) result = loop.run(task) # LoopResult(success, turns, total_tokens, session_id, breach_reason) </code></pre> <p>一个轮次的执行流程如下:</p> <ol> <li> <p><code>circuit_breaker.check(turn_count, accumulated_tokens)</code> —— 如果超过了任何限制,就会触发这个检查</p> </li> <li> <p><code>client.messages.create(...)</code> —— 实际的调用大型语言模型的操作</p> </li> <li> <p><code>ledger.write(...)</code> —— 将相关数据写入日志文件,仅支持追加操作</p> </li> <li> <p>如果 <code>stop_reason == "end_turn"</code>,则结束当前轮次;否则继续循环</p> </li> </ol> <p>在每次调用大型语言模型之前,都会进行预先检查,绝不例外。</p> <pre><code class="language-python">def run(self, task: str) -> LoopResult: session_id = self.spec.session_id messages: list[dict] = [{"role": "user", "content": task}] turn = 0 total_tokens = 0 try: while True: turn += 1 self.circuit_breaker.check(turn, total_tokens) started = time.perf_counter() response = self.client.messages.create( model=self.model, max_tokens=self.max_tokens, system=self._system_prompt(), messages=messages, ) elapsed_ms = int((time.perf_counter() - started) * 1000) turn_tokens = ( getattr(response.usage, "input_tokens", 0) + getattr(response_usage, "output_tokens", 0) ) total_tokens += turn_tokens text = self._text_from(response) messages.append({"role": "assistant", "content": text}) self.ledger.write( session_id=session_id, turn_count=turn, state_origin="llm", input_str=task, token_delta=turn_tokens, execution_time_ms=elapsed_ms, pass_fail=True, ) if getattr(response, "stop_reason", "end_turn") == "end_turn": return LoopResult( success=True, turns=turn, total_tokens=total_tokens, session_id=session_id, ) messages.append({"role": "user", "content": "继续") except CircuitBreakerError as err: self.ledger.write( session_id-session_id, turn_count=turn, state_origin="circuit_breaker", input_str=task, token_delta=0, execution_time_ms=0, pass_fail=False, breach_reason=err.reason, ) return LoopResult( success=False, turns=turn, total_tokens=total_tokens, session_id-session_id, breach_reason=err.reason, ) def _system_prompt(self) -> str: return ( "你是一个正在执行特定任务的代理。\n\n" f“这个任务的作用是:{self.spec.what_it_does}\n” f“这个任务不能完成的事情是:{self.spec.what_it_does_not}\n” f“完成任务后的结果应该是:{self/spec.done_looks_like}\n” ) @staticmethod def _text_from(response) -> str: content = getattr(response, "content", None) if not content: return "" block = content[0] return getattr(block, "text", "") or ""</code></pre> <p>有几点值得特别强调:</p> <ul> <li> <p><code>while True:</code>整个循环都被包裹在一个<code>try/except CircuitBreakerError</code>结构中。检查会在每一轮循环的开始阶段进行,因此无论是第1轮还是第6轮出现异常,都能被及时发现。</p> </li> <li> <p>在每行账本数据中,都使用<code>input_str=task</code>来获取原始任务内容,而不是最后的辅助信息。通过<code>input_hash</code>这一列,可以将整个运行过程中具有相同输入值的记录归为一组。</p> </li> <li> <p>对于每一个返回<code>False</code>结果的LLM循环,都会设置<code>pass_fail=True</code>;只有当循环出现异常时,这个标志才会被设置为<code>False</code>。这个通过/失败标志用于判断循环是否合法地完成了执行过程,而不是模型输出的结果是否正确。质量评估则是另一个需要考虑的问题。</p> </li> <li> <p><code>_system_prompt()</code>函数会使用所有的三个规范字段,而不仅仅是<code>done_looks_like</code>。模型同样需要了解“它不能做什么”这一方面的信息。</p> </li> <li> <p>应该使用<code>time.perf_counter()</code>而不是<code>time.time()</code>,因为前者是单调递增的,在运行过程中时钟时间的调整不会影响其计数值。</p> </li> </ul> <p><code>LoopResult.session_id</code>是从<code>spec.session_id</code>继承而来的。账本数据与规范文件之间无需通过复杂的连接操作就能建立对应关系,一个会话ID就代表整个可追踪的运行过程,从开始到结束。</p> <h2 id="heading-phase-5-the-review-surface">第5阶段:审核界面</h2> <p>断路器可以保护你的银行账户安全,账本记录则能记录发生了什么事情。但这两者都无法告诉你实际发生的情况是否与你最初做出的承诺相符。</p> <p>正是这种信息缺口导致了不良循环被批准通过——输出结果看起来很完美,仪表盘上也显示一切正常,但实际上并没有履行最初的承诺。审核人员看到这些结果后,认为它们是可以接受的,于是就签署了审批意见。但根本没有人去核实当初的承诺是否真正得到了履行。</p> <p>审核界面就是为了填补这一信息缺口而存在的。它从SQLite数据库中读取相关数据,组装出包含五个元素的审核框架,并在任何下游系统接收输出结果之前,强制进行对比检查。</p> <pre><code class="language-python">from review_surface import ReviewSurface rs = ReviewSurface(spec_db_path="spec.db", ledger_db_path="ledger.db") print(rs.render(session_id)) </code></pre> <p>以下是这个包含五个元素的审核框架的具体内容,按顺序排列如下:</p> <ol> <li> <p><strong>最初承诺的内容</strong>——这些信息来自规范文件,包括模型能做什么、不能做什么,以及完成任务后应该呈现什么样的结果。</p> </li> <li> <p><strong>验收标准</strong>——即<code>done_looks_like</code>字段所定义的明确基准。</p> </li> <li> <p><strong>差异分析</strong>——包括第一轮输入数据与最终输出结果之间的对比、已完成的任务轮次数、总token数量,以及循环是否出现了异常。</p> </li> <li> <p><strong>证据资料</code>——该会话期间所有的账本记录,包括每一轮的通过/失败情况、token数量的变化以及执行耗时等信息。</p> </li> <li> <p><strong>未解决的疑问</strong>——这些疑问是根据那些出现异常的记录和失败的循环环节总结出来的。如果所有数据都正常,这一栏就会显示为空。</p> </li> </ol> <p><当审核人确认满意时,他们会出具相应的证明:></p> <pre><code class="language-python">attestation = rs.attest( session_id=result.session_id, reviewer="daniel", notes="输出结果符合规范。已批准。" ) print(attestation.frame_hash) </code></pre> <p><code>.attest()</code> 方法会将数据写入 <code>ledger.db</code> 文件中的 <code>attestations</code> 表中。<code>frame_hash</code> 是对规范框架数据的 SHA-256 散列值——对于审核同一会话内容的不同审核人员来说,这个散列值是相同的。它相当于一种审计凭证,能够证明审核人员看到的确实是原始数据,而不是任何摘要或改写内容。</p> <p>批准操作确认了整个流程已经执行完毕;而认证过程则证明了审核人员确实将输出结果与初始承诺进行了对比。当这个流程涉及到受监管的内容时,所产生的文件就会属于不同的法律类别。</p> <pre><code class="language-python">@dataclass(frozen=True) class ReviewFrame: session_id: str original_promise: SpecResult acceptance_criteria: str diff: DiffResult evidence: tuple # 元组形式,元素为LedgerRow类型 unresolved_assumptions: tuple # 元组形式,元素为str类型 created_at: str </code></pre> <p><code>ReviewFrame</> 类被设为不可修改的状态,原因与 <code>SpecResult</> 类相同——因为这些数据属于证据,而不是草稿。<code>evidence</code> 和 <code>unresolved_assumptions</code> 被定义为元组,是因为列表无法被哈希处理,而不可修改的数据结构需要使用可哈希的字段。</p> <p>关于审核流程的完整端到端实现代码可以在仓库中的 <code>examples/review_example.py</code> 文件中找到。在任何会话完成之后运行这个脚本,它就会生成包含五个元素的框架数据,提示用户进行认证操作,如果你批准的话,还会生成相应的审计凭证。</p> <p>整个流程需要你的确认才能继续执行;在得到你的批准之前,下游系统是无法收到任何处理结果的。</p> <h2 id="heading-phase-6-a-real-example-seo-audit-agent">阶段 6:一个实际案例——SEO审核代理</h2> <p>只有面对真实的问题时,这种设计模式才有意义。我的 <a href="https://github.com/dannwaneri/seo-agent">seo-agent</a> 项目就是基于这种架构设计的。</p> <p>SEO审核工作本身具有固定的节奏:首先爬取网站数据,找出存在的问题并进行修复,然后等待搜索引擎重新索引这些内容。如果持续运行这个审核代理程序,也不会改变这一节奏;它只会浪费一些资源,在那些真正重要的环节之间消耗多余的“代币”。将定时任务与这个流程结合使用,才是合理的架构设计。</p> <pre><code class="language-python"># examples/seo_audit_example.py import requests from bs4 import BeautifulSoup import anthropic from spec_writer import SpecWriter from circuit_breaker import CircuitBreaker from ledger import Ledger from agent_loop import AgentLoop def crawl_url(url: str) -> str: response = requests.get(url, timeout=10) soup = BeautifulSoup(response.text, "html.parser") title = soup.find("title") meta_desc = soup.find("meta", attrs={"name": "description"}) h1_tags = soup.find_all("h1") return ( f"URL: {url}\n" f"标题: {title.text if title else '未找到'}\n" f>元描述: {\n}{meta_desc['content'] if meta_desc else '未找到'}\n" f>"h1标签数量: {len(h1_tags)}\n" f>"h1标签内容: {[h.text[:50] for h in h1_tags]}" ) def run_seo_audit(url: str) -> None: # 第一步:在流程开始之前定义完成标准 spec = SpecWriter(db_path="spec.db").run() # 第二步:初始化断路器和账本 breaker = CircuitBreaker(turn_limit=5, token_limit=15000) ledger = Ledger(db_path="ledger.db") client = anthropic.Anthropic() # 第三步:爬取指定网站的数据 site_data = crawl_url(url) # 第四步:运行审核流程 # AgentLoop会内部捕获CircuitBreakerError异常并返回相应的结果 # 因此不需要使用try/except语句来处理CircuitBreakerError异常 loop = AgentLoop(spec, breaker, ledger, client) result = loop.run( f"请审核此页面的SEO问题:\n\n{site_data}" ) # 第五步:输出审核结果 print(f"\n结果: {'SUCCESS' if result.success else '失败'}") if not result.success: print(f>失败原因: {result.breach_reason}") print(f>尝试次数: {result.turns} | 代币消耗量: {result.total_tokens}") print("\n审核记录:") for row in ledger.get_session(result.session_id): status = "通过" if row.pass_fail else "失败" print(f" 尝试次数 {row_turn_count}: {status} | " f"消耗代币数 {row.token_delta} | 执行耗时:{rowexecution_time_ms}毫秒") if __name__ == "__main__": import sys run_seo_audit(sys.argv[1] if len(sys.argv) > 1 else "https://example.com") </code></pre> <p>运行它:</p> <pre><code class="language-bash">python examples/seo_audit_example.py https://yourdomain.com </code></pre> <p>规范编写工具会引导你完成操作。循环会持续运行,如果超过了限制,断路器就会启动;同时,账本会记录下每一次操作的结果。最终输出结果会显示在你面前,你可以据此决定需要修改哪些地方。</p> <p>这个循环是为你服务的,而不是在无意义的空虚中运行。</p> <h2 id="heading-pluggable-llm-client">可插拔的LLM客户端</h2> <p>这个循环可以与任何符合<code>LLMClient</code>协议的客户端配合使用(默认使用的是Anthropic)。你也可以通过编写大约20行的适配代码,使用自己定制的客户端。</p> <pre><code class="language-python"># agent_loop.py from typing import Protocol, runtime_checkable @runtime_checkable class MessagesEndpoint(Protocol): def create(self, *, model: str, max_tokens: int, system: str, messages: list) -> object: ... @runtime_checkable class LLMClient(Protocol): messages: MessagesEndpoint </code></pre> <p><code>messages</code>被定义为一个实例属性(而不是嵌套类),因为真实的Anthropic SDK就是这么设计的——通过<code>anthropic.Anthropic().messages.create(...)</code>来使用这个接口。如果将其定义为嵌套类,那么真实的客户端就无法满足<code>LLMClient</code>协议的要求了。<code>@runtime_checkable</code>装饰器可以帮助我们通过<code>isinstance(client, LLMClient)</code>来检查代码是否符合规范,而仓库中的测试用例也是利用这个判断方法来检测<code>FakeClient</code>这个模拟客户端的。</p> <p>下面是一个OpenAI适配器的示例(这个示例仅用于说明目的,实际生产环境中使用的适配器还需要处理数据流、工具使用方式以及错误信息等问题):</p> <pre><code class="language-python"># openai_adapter.py — 仅为说明用途的伪代码,并非可用于生产环境。 from openai import OpenAI as _OpenAI class _MessagesAdapter: def __init__(self, client): self._client = client def create(self, *, model, max_tokens, system, messages): completion = self._client.chat.completions.create( model=model, max_tokens=max_tokens, messages=[{"role": "system", "content": system}] + messages, ) # 将OpenAI返回的结果转换成Anthropic可以处理的格式 # AgentLoop会接收如下格式的响应:response.usage.{input,output}_tokens, # response.content[0].text, response.stop_reason. return _adapt_responsecompletion) class OpenAIAdapter: def __init__(self, api_key: str): self._client = _OpenAI(api_key=api_key) self.messages = _MessagesAdapter(self._client) # 这是一个实例属性,而不是嵌套类 </code></pre> <p>适配器模式确实值得专门进行讲解。因为不同的提供者API其数据结构是不一样的。Anthropic将<code>system</code>信息放在最顶层,而OpenAI则将其包含在<code>messages</code>数组中。通过编写大约20行的适配代码,我们就可以让这个循环机制与不同的提供者API兼容,而无需重新修改任何代码。需要注意的是,<code>self.messages</code>是在<code>__init__</code>方法中被初始化的,因此它是每个适配器实例的真实属性,其数据结构与真实的SDK是一致的。</p> <h2 id="heading-running-the-tests">运行测试</h2> <pre><code class="language-bash">python -m pytest tests/ </code></pre> <p>要查看代码覆盖率,请执行以下命令:</p> <pre><code class="language-bash">python -m coverage run --source=circuit_breaker,ledger,spec_writer,agent_loop,review_surface -m pytest tests/ python -m coverage report -m </code></pre> <p>共有80个测试用例,所有5个核心模块的代码覆盖率均为100%。在<code>tests/test_agent_loop.py</code>文件中,使用了<code>FakeClient</code>作为测试对象来执行相关测试。这种设计通过“鸭子类型”机制满足了<code>LLMClient</code>协议的要求:将<code>messages</code>属性设置为<code>self</code>,因此<code>client.messages.create(...)</code>方法会调用同一个对象,并为每种测试场景生成预设的响应结果。只需克隆该代码仓库并运行<code>pytest</code>命令,就能看到所有80个测试用例都能成功通过,而且无需连接网络或使用API密钥。</p> <p><code>circuit_breaker.py</code>文件的代码覆盖率也为100%,没有任何未经过测试的路径。该模块是负责确保系统财务安全的关键组件,其中的所有功能路径都经过了测试。</p> <h2 id="heading-what-you've-built">你所构建的内容</h2> <p>通过本教程,你构建了5个独立可用的小型模块。</p> <table> <thead> <tr> <th>模块名称</th> <th>作用</th> <th>代码行数</th> </tr> </thead> <tbody> <tr> <td><code>spec_writer.py</code></td> <td>在循环开始之前强制设置三个固定答案</td> <td>104行</td> </tr> <tr> <td><code>circuit_breaker.py</code></td> <td>对轮次执行次数和代币数量进行严格限制</td> <td>41行</td> </tr> <tr> <td><code>ledger.py</code></td> <td>仅支持追加操作的SQLite审计日志系统</td> <td>113行</td> </tr> <tr> <td><code>agent_loop.py</code></td> <td>一个能够同时满足各种要求的循环机制</td> <td>128行</td> </tr> <tr> <td><code>review_surface.py</code></td> <td>用于组装包含5个元素的数据结构并记录人工验证信息</td> <td>114行</td> </tr> </tbody> </table> <p>这种设计模式的核心在于:上游模块负责设定规则边界,下游模块则负责执行这些规则;两者都不依赖模型来自我监管。</p> <p>一个没有退出条件的循环并不具备自主性,它实际上只是个等待被执行的操作而已。</p> <p>在开始开发之前,就必须明确“完成目标应该是什么样子”。这才是真正重要的工作,而且这一点从来都没有改变。</p> <h2 id="heading-next-steps">后续步骤</h2> <p>该代码仓库的地址是<a href="https://github.com/dannwaneri/production-safe-agent-loop">github.com/dannwaneri/production-safe-agent-loop</a>。</p> <p>如果你想进一步深入研究,还有三个自然的扩展方向:</p> <h3 id="heading-1-graduation-to-distributed-systems">1. 向分布式系统发展</h3> <p>对于那些仅用于独立序列执行的系统来说,SQLite数据库是完全可以满足需求的。但一旦有多个代理进程共享同一数据状态,就需要使用可序列化的隔离机制——因为对扁平化JSON数据的并发写入操作可能会导致数据被破坏。README文件中详细说明了在哪些情况下系统需要升级到分布式架构。</p> <h3 id="heading-2-cryptographic-signing">2. 加密签名技术</h3> <p>对于那些在运行过程中没有审计人员在场的环境来说,仅使用SQLite数据库是不够的。数据库管理员可以执行<code>UPDATE</code>操作来修改数据,但这种做法存在安全隐患。而使用Ed25519加密算法为每条记录添加签名,则可以确保日志内容在执行后被妥善保护。不过,这个话题属于另一个教程的内容了。</p> <h3 id="heading-wiring-a-cron-job">配置 Cron 作业</h3> <p>实际上,SEO审计代理的真正工作方式并不是全天候自动运行。它是由 cron 作业按照预定时间表来执行的,当发现异常情况时才会停止运行。<code>0 3 * * 2 python examples/seo_audit_example.py https://yourdomain.com</code>——这就是整个配置流程。这个循环会持续运行,而不会陷入无休止的重复状态。</p> <p>如果您需要为自己的系统架构配置类似的机制(例如断路器、审计日志功能,或是确保代理程序在生产环境中能够安全运行的机制),我愿意提供自由职业服务。<a href="https://dannwaneri.com/ai-agents/">dannwaneri.com/ai-agents/</a></p> </div> <section class="mt-12 border-t border-slate-200 pt-10 dark:border-slate-800"><h2 class="text-xl font-bold text-slate-900 dark:text-white">相关文章</h2><div class="mt-6 grid gap-6 md:grid-cols-2"><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/how-to-elevate-your-database-game-supercharging-query-performance-with-postgres-fdw" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div> <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span> </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar"> <use href="#ai:tabler:calendar"></use> </svg> <time datetime="Sat Feb 21 2026 04:31:05 GMT+0800 (China Standard Time)">2026年2月20日</time> </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/how-to-elevate-your-database-game-supercharging-query-performance-with-postgres-fdw" class="transition hover:text-violet-600 dark:hover:text-violet-300">如何提升数据库的性能:利用Postgres FDW来显著增强查询速度</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">外部数据封装机制使得远程的Postgres表看起来就像本地的表一样。正是这种便利 &hellip; Read More</p> <a href="/articles/how-to-elevate-your-database-game-supercharging-query-performance-with-postgres-fdw" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300"> 阅读全文 <svg width="1em" height="1em" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right"> <symbol id="ai:tabler:arrow-right" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-6 6l6-6m-6-6l6 6"/></symbol><use href="#ai:tabler:arrow-right"></use> </svg> </a> </div> </article><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/how-to-analyze-insider-transactions-with-python-a-ceo-buying-case-study" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div> <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span> </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar"> <use href="#ai:tabler:calendar"></use> </svg> <time datetime="Wed Jul 15 2026 04:24:57 GMT+0800 (China Standard Time)">2026年7月14日</time> </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/how-to-analyze-insider-transactions-with-python-a-ceo-buying-case-study" class="transition hover:text-violet-600 dark:hover:text-violet-300">如何使用Python分析内幕交易:以某位首席执行官的购股行为为例进行案例研究</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">当一家公司的股价大幅下跌后,首席执行官却选择购买该公司的股票,人们很容易将这一行 &hellip; Read More</p> <a href="/articles/how-to-analyze-insider-transactions-with-python-a-ceo-buying-case-study" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300"> 阅读全文 <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right"> <use href="#ai:tabler:arrow-right"></use> </svg> </a> </div> </article><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/the-react-native-live-activities-handbook-how-to-build-ios-live-activities-and-android-16-live-updates" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div> <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span> </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar"> <use href="#ai:tabler:calendar"></use> </svg> <time datetime="Wed Jul 15 2026 04:24:57 GMT+0800 (China Standard Time)">2026年7月14日</time> </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/the-react-native-live-activities-handbook-how-to-build-ios-live-activities-and-android-16-live-updates" class="transition hover:text-violet-600 dark:hover:text-violet-300">《React Native实时活动手册:如何开发iOS实时应用及Android 16版本的实时更新功能》</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">“实时活动”功能是指在配送员接近时,该功能会自动显示在锁屏界面上,并且无需你打开 &hellip; Read More</p> <a href="/articles/the-react-native-live-activities-handbook-how-to-build-ios-live-activities-and-android-16-live-updates" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300"> 阅读全文 <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right"> <use href="#ai:tabler:arrow-right"></use> </svg> </a> </div> </article><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/how-to-build-a-headless-wordpress-frontend-with-astro-ssr-on-cloudflare-pages" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div> <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span> </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar"> <use href="#ai:tabler:calendar"></use> </svg> <time datetime="Wed Apr 22 2026 18:54:35 GMT+0800 (China Standard Time)">2026年4月22日</time> </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/how-to-build-a-headless-wordpress-frontend-with-astro-ssr-on-cloudflare-pages" class="transition hover:text-violet-600 dark:hover:text-violet-300">如何在 Cloudflare Pages 上使用 Astro SSR 构建一个无头 WordPress 前端界面</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">本教程将向您展示如何将WordPress作为无头内容管理系统来使用,并将其前端框 &hellip; Read More</p> <a href="/articles/how-to-build-a-headless-wordpress-frontend-with-astro-ssr-on-cloudflare-pages" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300"> 阅读全文 <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right"> <use href="#ai:tabler:arrow-right"></use> </svg> </a> </div> </article></div></section> </article> <div class="w-full shrink-0 lg:w-56"> <aside class="w-full min-w-0 space-y-8 lg:sticky lg:top-24"> <section> <h2 class="text-sm font-semibold tracking-wide text-slate-900 dark:text-white">内容栏目</h2> <ul class="mt-3 flex flex-col gap-1"> <li> <a href="/articles/category/industry" class="block truncate rounded-lg px-2.5 py-1.5 text-sm font-medium transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300"> 行业洞察 </a> </li><li> <a href="/articles/category/technology" class="block truncate rounded-lg px-2.5 py-1.5 text-sm font-medium transition bg-violet-50 text-violet-700 dark:bg-violet-950/50 dark:text-violet-300"> 技术实践 </a> </li><li> <a href="/articles/category/product" class="block truncate rounded-lg px-2.5 py-1.5 text-sm font-medium transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300"> 产品动态 </a> </li> </ul> </section> <section> <h2 class="text-sm font-semibold tracking-wide text-slate-900 dark:text-white">文章分类</h2> <ul class="mt-3 flex flex-col gap-1"> <li> <a href="/articles/categories/%E5%AE%89%E5%85%A8" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="安全"> 安全 </a> </li><li> <a href="/articles/categories/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="操作系统"> 操作系统 </a> </li><li> <a href="/articles/categories/product" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="产品方案"> 产品方案 </a> </li><li> <a href="/articles/categories/%E4%BA%A7%E5%93%81%E8%BF%90%E8%90%A5" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="产品运营"> 产品运营 </a> </li><li> <a href="/articles/categories/%E5%A4%A7%E6%95%B0%E6%8D%AE" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition bg-violet-50 font-medium text-violet-700 dark:bg-violet-950/50 dark:text-violet-300" title="大数据"> 大数据 </a> </li><li> <a href="/articles/categories/printorder" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="电商打单"> 电商打单 </a> </li><li> <a href="/articles/categories/%E6%95%85%E9%9A%9C%E9%A2%84%E8%AD%A6" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="故障预警"> 故障预警 </a> </li><li> <a href="/articles/categories/%E6%9E%B6%E6%9E%84" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="架构"> 架构 </a> </li><li> <a href="/articles/categories/openapi" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="开放平台"> 开放平台 </a> </li><li> <a href="/articles/categories/uncategorized" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="默认"> 默认 </a> </li><li> <a href="/articles/categories/allsite" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="全渠道OMS+WMS"> 全渠道OMS+WMS </a> </li><li> <a href="/articles/categories/hotnews" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="热点关注"> 热点关注 </a> </li><li> <a href="/articles/categories/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="人工智能"> 人工智能 </a> </li><li> <a href="/articles/categories/%E8%BD%AF%E4%BB%B6%E5%BC%80%E5%8F%91" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="软件开发"> 软件开发 </a> </li><li> <a href="/articles/categories/%E6%95%B0%E6%8D%AE%E5%BA%93" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="数据库"> 数据库 </a> </li><li> <a href="/articles/categories/%E7%A7%BB%E5%8A%A8" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="移动"> 移动 </a> </li><li> <a href="/articles/categories/%E4%B8%93%E5%AE%B6%E8%A7%82" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="专家观"> 专家观 </a> </li><li> <a href="/articles/categories/expertpoint" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="专家观点"> 专家观点 </a> </li><li> <a href="/articles/categories/iot" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="IoT"> IoT </a> </li> </ul> </section> <section> <h2 class="text-sm font-semibold tracking-wide text-slate-900 dark:text-white">文章标签</h2> <ul class="mt-3 flex flex-wrap gap-1.5"> <li class="max-w-full"> <a href="/articles/tags/%E6%95%B0%E6%8D%AE" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="数据"> 数据 </a> </li><li class="max-w-full"> <a href="/articles/tags/strong" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="strong"> strong </a> </li><li class="max-w-full"> <a href="/articles/tags/data" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="data"> data </a> </li><li class="max-w-full"> <a href="/articles/tags/code" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="code"> code </a> </li><li class="max-w-full"> <a href="/articles/tags/span" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="span"> span </a> </li><li class="max-w-full"> <a href="/articles/tags/ai" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="AI"> AI </a> </li><li class="max-w-full"> <a href="/articles/tags/li" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="li"> li </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%95%B0%E6%8D%AE%E5%BA%93" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="数据库"> 数据库 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%A8%A1%E5%9E%8B" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="模型"> 模型 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BD%BF%E7%94%A8" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="使用"> 使用 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="人工智能"> 人工智能 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%B8%AD" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="中"> 中 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%9C%8D%E5%8A%A1" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="服务"> 服务 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BC%9A" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="会"> 会 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%8A%80%E6%9C%AF" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="技术"> 技术 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E7%B3%BB%E7%BB%9F" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="系统"> 系统 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E5%AE%89%E5%85%A8" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="安全"> 安全 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E7%94%A8%E6%88%B7" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="用户"> 用户 </a> </li><li class="max-w-full"> <a href="/articles/tags/em" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="em"> em </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BB%A3%E7%A0%81" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="代码"> 代码 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BB%A3%E7%A0%81%EF%BC%8C%E4%BC%9A%E7%9A%84%E3%80%82" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-violet-600 text-white" title="代码,会的。"> 代码,会的。 </a> </li><li class="max-w-full"> <a href="/articles/tags/nbsp" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="nbsp"> nbsp </a> </li><li class="max-w-full"> <a href="/articles/tags/ltr" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="ltr"> ltr </a> </li><li class="max-w-full"> <a href="/articles/tags/variable" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="variable"> variable </a> </li><li class="max-w-full"> <a href="/articles/tags/iot" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="iot"> iot </a> </li><li class="max-w-full"> <a href="/articles/tags/%E8%AE%BE%E5%A4%87" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="设备"> 设备 </a> </li><li class="max-w-full"> <a href="/articles/tags/href" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="href"> href </a> </li><li class="max-w-full"> <a href="/articles/tags/kafka" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="kafka"> kafka </a> </li><li class="max-w-full"> <a href="/articles/tags/python" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="python"> python </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BA%91" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="云"> 云 </a> </li> </ul> </section> </aside> </div> </div> </div> </main> <footer class="relative not-prose border-t border-slate-200/80 bg-gradient-to-b from-slate-50 to-white dark:border-slate-800 dark:from-slate-950 dark:to-slate-900"> <div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 intersect-once intersect-quarter intersect-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"> <div class="grid grid-cols-12 gap-10 py-12 md:gap-8 md:py-14 lg:gap-12"> <!-- Brand + WeChat --> <div class="col-span-12 lg:col-span-4"> <a class="inline-block" href="/" aria-label="返回首页"> <span class="inline-flex items-end gap-3 self-center whitespace-nowrap"> <img src="/_image?href=%2F_astro%2Fcheeli-logo.DIEHWBc7.png&w=108&h=32&f=webp" loading="eager" decoding="async" alt="Cheeli" style="object-fit:cover;object-position:center;max-width:108px;max-height:32px;aspect-ratio:3.375;width:100%" width="108" height="32" class="block h-8 w-auto shrink-0 invert hue-rotate-180 dark:invert-0 dark:hue-rotate-0"> <span class="relative pb-px pl-3 text-[15px] font-semibold leading-none tracking-[0.14em] text-slate-800 before:absolute before:bottom-0 before:left-0 before:h-3.5 before:w-px before:bg-slate-300/80 dark:text-slate-100 dark:before:bg-slate-600/80 md:text-base"> 知力科技 </span> </span> </a> <p class="mt-4 text-sm font-semibold text-slate-800 dark:text-slate-100">蜂巢数字商业平台</p> <p class="mt-2 max-w-sm text-sm leading-6 text-slate-600 dark:text-slate-400">连接商业生态,赋能企业智能经营。</p> <div class="mt-6 inline-flex items-start gap-4 rounded-2xl border border-slate-200/80 bg-white p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/80"> <img src="/_astro/wechat-cheeli-tech-qrcode.CYWhZFv0.jpg" alt="知力科技官方微信公众号二维码" width="88" height="88" class="block h-[88px] w-[88px] shrink-0 rounded-xl bg-white object-contain" loading="lazy" decoding="async"> <div class="pt-1"> <p class="text-sm font-semibold text-slate-900 dark:text-white">官方公众号</p> <p class="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">扫码关注,获取产品动态与行业洞察</p> </div> </div> <div class="mt-6 flex flex-wrap gap-x-4 gap-y-2 text-sm"> <a class="text-slate-500 transition hover:text-blue-600 dark:text-slate-400 dark:hover:text-blue-300" href="/privacy"> 隐私政策 </a><a class="text-slate-500 transition hover:text-blue-600 dark:text-slate-400 dark:hover:text-blue-300" href="/terms"> 服务条款 </a> </div> </div> <!-- Navigation columns --> <div class="col-span-12 grid grid-cols-2 gap-8 sm:grid-cols-3 lg:col-span-8 lg:gap-10"> <div> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500"> 产品 </p> <ul class="mt-4 space-y-3"> <li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/products/open-platform"> 蜂巢开放平台 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/products/omnichannel"> 蜂巢全渠道 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/products/ai-agent"> AI智能应用 </a> </li> </ul> </div><div> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500"> 解决方案 </p> <ul class="mt-4 space-y-3"> <li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/ecommerce-omnichannel"> 电商全渠道解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/digital-goods"> 虚拟商品与数字权益解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/order-fulfillment"> 企业订单履约解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/private-mall"> 私域商城解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/digital-connection"> 企业数字化连接解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/ai-operations"> AI智能运营解决方案 </a> </li> </ul> </div><div> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500"> 生态与内容 </p> <ul class="mt-4 space-y-3"> <li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/developers"> 开发者中心 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/cases"> 客户案例 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/articles"> 蜂巢洞察 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/about"> 关于我们 </a> </li> </ul> </div> </div> </div> <!-- Legal bar --> <div class="flex flex-col gap-4 border-t border-slate-200/80 py-6 md:flex-row md:items-center md:justify-between dark:border-slate-800 md:py-7"> <div class="text-xs leading-6 text-slate-500 dark:text-slate-400"> <p class="text-slate-600 dark:text-slate-300">2026 Copyright © 上海知力信息科技有限公司 · All Rights Reserved</p> <p class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1"> <a class="transition hover:text-blue-600 dark:hover:text-blue-300" href="https://beian.miit.gov.cn/" rel="noopener noreferrer" target="_blank"> 沪ICP备09012674号 </a> <span class="text-slate-300 dark:text-slate-600" aria-hidden="true">|</span> <a class="inline-flex items-center gap-1 transition hover:text-blue-600 dark:hover:text-blue-300" href="https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=31011502018658" rel="noopener noreferrer" target="_blank"> <img src="/_image?href=%2F_astro%2Fbeian-logo.hBd0lRJE.png&w=16&h=16&f=webp" loading="lazy" decoding="async" alt style="object-fit:cover;object-position:center;width:16px;height:16px;object-position:top left" width="16" height="16" class="block h-4 w-4 shrink-0"> 沪公网安备 31011502018658号 </a> <span class="text-slate-300 dark:text-slate-600" aria-hidden="true">|</span> <span>增值电信业务许可证:沪B2-20220251</span> </p> </div> <ul class="flex shrink-0 gap-1 md:justify-end"> <li> <a class="inline-flex items-center rounded-lg p-2.5 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100" aria-label="RSS" href="/rss.xml"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-5 w-5" data-icon="tabler:rss"> <use href="#ai:tabler:rss"></use> </svg> </a> </li> </ul> </div> </div> </footer> <aside id="cheeli-float-panel" class="cheeli-float" data-collapsed="false" aria-label="客服联系方式" data-astro-cid-lduetl6a> <button type="button" class="cheeli-float__toggle" data-float-toggle aria-expanded="true" aria-controls="cheeli-float-panel-body" data-astro-cid-lduetl6a> <span class="cheeli-float__toggle-open" aria-hidden="true" data-astro-cid-lduetl6a>收起</span> <span class="cheeli-float__toggle-closed" aria-hidden="true" data-astro-cid-lduetl6a>客服</span> </button> <div id="cheeli-float-panel-body" class="cheeli-float__body" data-astro-cid-lduetl6a> <div class="cf-card cf-card--avatar" data-astro-cid-lduetl6a> <div class="cf-avatar" data-astro-cid-lduetl6a> <img src="/_astro/ocpm.2osPe16p.png" alt="蜂巢客服顾问" width="120" height="130" loading="lazy" decoding="async" data-astro-cid-lduetl6a> </div> <a class="cf-phone" href="tel:400-002-5767" data-astro-cid-lduetl6a>☎ 400-002-5767</a> </div> <div class="cf-card" data-astro-cid-lduetl6a> <img class="cf-qrcode" src="/_astro/wechat.BcEd01Bh.png" alt="客服微信二维码" width="130" height="140" loading="lazy" decoding="async" data-astro-cid-lduetl6a> <div class="cf-title" data-astro-cid-lduetl6a>扫码添加客服微信</div> </div> <a class="cf-btn white" href="https://wpa.qq.com/msgrd?v=3&uin=273785004&site=qq&menu=yes" target="_blank" rel="noopener noreferrer" aria-label="QQ 客服 273785004" data-astro-cid-lduetl6a> <img src="/_astro/qq.Bq_2h58v.png" alt="" width="16" height="18" loading="lazy" decoding="async" data-astro-cid-lduetl6a> <span data-astro-cid-lduetl6a>:273785004</span> </a> </div> </aside> <script type="module">const c="cheeli-float-contact-collapsed";function d(t){if(t.dataset.bound==="true")return;t.dataset.bound="true";const e=t.querySelector("[data-float-toggle]");if(!e)return;const l=n=>{t.dataset.collapsed=n?"true":"false",e.setAttribute("aria-expanded",n?"false":"true");try{localStorage.setItem(c,n?"1":"0")}catch{}};let a=!1;try{a=localStorage.getItem(c)==="1"}catch{a=!1}l(a),e.addEventListener("click",()=>{l(t.dataset.collapsed!=="true")})}function o(){const t=document.getElementById("cheeli-float-panel");t instanceof HTMLElement&&d(t)}o();document.addEventListener("astro:page-load",o);</script> <script>(function(){const defaultTheme = "system"; if (window.basic_script) { return; } window.basic_script = true; function applyTheme(theme) { if (theme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } } const initTheme = function () { if ((defaultTheme && defaultTheme.endsWith(':only')) || (!localStorage.theme && defaultTheme !== 'system')) { applyTheme(defaultTheme.replace(':only', '')); } else if ( localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) ) { applyTheme('dark'); } else { applyTheme('light'); } }; initTheme(); function attachEvent(selector, event, fn) { const matches = typeof selector === 'string' ? document.querySelectorAll(selector) : selector; if (matches && matches.length) { matches.forEach((elem) => { elem.addEventListener(event, (e) => fn(e, elem), false); }); } } const onLoad = function () { let lastKnownScrollPosition = window.scrollY; let ticking = true; attachEvent('#header nav', 'click', function () { document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); document.body.classList.remove('overflow-hidden'); document.getElementById('header')?.classList.remove('h-screen'); document.getElementById('header')?.classList.remove('expanded'); document.getElementById('header')?.classList.remove('bg-page'); document.querySelector('#header nav')?.classList.add('hidden'); document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); }); attachEvent('[data-aw-toggle-menu]', 'click', function (_, elem) { elem.classList.toggle('expanded'); document.body.classList.toggle('overflow-hidden'); document.getElementById('header')?.classList.toggle('h-screen'); document.getElementById('header')?.classList.toggle('expanded'); document.getElementById('header')?.classList.toggle('bg-page'); document.querySelector('#header nav')?.classList.toggle('hidden'); document.querySelector('#header > div > div:last-child')?.classList.toggle('hidden'); }); attachEvent('[data-aw-toggle-color-scheme]', 'click', function () { if (defaultTheme.endsWith(':only')) { return; } Observer.removeAnimationDelay(); document.documentElement.classList.toggle('dark'); localStorage.theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; }); attachEvent('[data-aw-social-share]', 'click', function (_, elem) { const network = elem.getAttribute('data-aw-social-share'); const url = encodeURIComponent(elem.getAttribute('data-aw-url')); const text = encodeURIComponent(elem.getAttribute('data-aw-text')); let href; switch (network) { case 'facebook': href = `https://www.facebook.com/sharer.php?u=${url}`; break; case 'twitter': href = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; break; case 'linkedin': href = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${text}`; break; case 'whatsapp': href = `https://wa.me/?text=${text}%20${url}`; break; case 'mail': href = `mailto:?subject=%22${text}%22&body=${text}%20${url}`; break; default: return; } const newlink = document.createElement('a'); newlink.target = '_blank'; newlink.href = href; newlink.click(); }); const screenSize = window.matchMedia('(max-width: 767px)'); screenSize.addEventListener('change', function () { document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); document.body.classList.remove('overflow-hidden'); document.getElementById('header')?.classList.remove('h-screen'); document.getElementById('header')?.classList.remove('expanded'); document.getElementById('header')?.classList.remove('bg-page'); document.querySelector('#header nav')?.classList.add('hidden'); document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); }); function applyHeaderStylesOnScroll() { const header = document.querySelector('#header[data-aw-sticky-header]'); if (!header) return; if (lastKnownScrollPosition > 60 && !header.classList.contains('scroll')) { header.classList.add('scroll'); } else if (lastKnownScrollPosition <= 60 && header.classList.contains('scroll')) { header.classList.remove('scroll'); } ticking = false; } applyHeaderStylesOnScroll(); attachEvent([document], 'scroll', function () { lastKnownScrollPosition = window.scrollY; if (!ticking) { window.requestAnimationFrame(() => { applyHeaderStylesOnScroll(); }); ticking = true; } }); }; const onPageShow = function () { document.documentElement.classList.add('motion-safe:scroll-smooth'); const elem = document.querySelector('[data-aw-toggle-menu]'); if (elem) { elem.classList.remove('expanded'); } document.body.classList.remove('overflow-hidden'); document.getElementById('header')?.classList.remove('h-screen'); document.getElementById('header')?.classList.remove('expanded'); document.querySelector('#header nav')?.classList.add('hidden'); }; window.onload = onLoad; window.onpageshow = onPageShow; document.addEventListener('astro:after-swap', () => { initTheme(); onLoad(); onPageShow(); }); })();</script> <script> /* Inspired by: https://github.com/heidkaemper/tailwindcss-intersect */ const Observer = { observer: null, delayBetweenAnimations: 100, animationCounter: 0, elements: null, start(isNavigation) { if (this.observer) { this.observer.disconnect(); } this.animationCounter = 0; const selectors = [ '[class*=" intersect:"]', '[class*=":intersect:"]', '[class^="intersect:"]', '[class="intersect"]', '[class*=" intersect "]', '[class^="intersect "]', '[class$=" intersect"]', ]; this.elements = Array.from(document.querySelectorAll(selectors.join(','))); const getThreshold = (element) => { if (element.classList.contains('intersect-full')) return 0.99; if (element.classList.contains('intersect-half')) return 0.5; if (element.classList.contains('intersect-quarter')) return 0.25; return 0; }; this.elements.forEach((el) => { el.setAttribute('no-intersect', ''); el._intersectionThreshold = getThreshold(el); }); if (isNavigation) { const vh = window.innerHeight; this.elements.forEach((el) => { const rect = el.getBoundingClientRect(); if (rect.top < vh && rect.bottom > 0) { el.removeAttribute('no-intersect'); el.setAttribute('data-animated', 'true'); el.style.animationDuration = '0s'; } }); } const callback = (entries) => { entries.forEach((entry) => { requestAnimationFrame(() => { const target = entry.target; const intersectionRatio = entry.intersectionRatio; const threshold = target._intersectionThreshold; if (target.classList.contains('intersect-no-queue')) { if (entry.isIntersecting) { target.removeAttribute('no-intersect'); if (target.classList.contains('intersect-once')) { this.observer.unobserve(target); } } else { target.setAttribute('no-intersect', ''); } return; } if (intersectionRatio >= threshold) { if (!target.hasAttribute('data-animated')) { target.removeAttribute('no-intersect'); target.setAttribute('data-animated', 'true'); target.style.animationDuration = ''; const delay = this.animationCounter * this.delayBetweenAnimations; this.animationCounter++; target.style.transitionDelay = `${delay}ms`; target.style.animationDelay = `${delay}ms`; if (target.classList.contains('intersect-once')) { this.observer.unobserve(target); } } } else { target.setAttribute('no-intersect', ''); target.removeAttribute('data-animated'); target.style.transitionDelay = ''; target.style.animationDelay = ''; target.style.animationDuration = ''; this.animationCounter = 0; } }); }); }; this.observer = new IntersectionObserver(callback, { threshold: [0, 0.25, 0.5, 0.99] }); this.elements.forEach((el) => { if (!el.hasAttribute('data-animated')) { this.observer.observe(el); } }); }, /* REF: #643; We need to remove the delay to fix flickering/delay when toggling the theme. Observer only removes them after data-animated is gone (out of view). */ removeAnimationDelay() { this.elements.forEach((el) => { if (el.getAttribute('data-animated') === 'true') { el.style.transitionDelay = ''; el.style.animationDelay = ''; } }); }, }; Observer.start(false); document.addEventListener('astro:after-swap', () => { Observer.start(true); }); </script> </body></html>